1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 21:34:04 +00:00

Merge branch 'master' into database-cache-bootstrapper

This commit is contained in:
lukinovec 2025-07-22 19:32:57 +02:00
commit 2cfa8831a3
113 changed files with 3035 additions and 1373 deletions

View file

@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- laravel: "^11.0" - laravel: "^12.0"
php: "8.4" php: "8.4"
steps: steps:

View file

@ -10,12 +10,20 @@ jobs:
steps: steps:
- name: Prepare composer version constraint prefix - name: Prepare composer version constraint prefix
run: | run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
# For refs like "refs/tags/v3.9.0", remove "refs/tags/v" prefix to get just "3.9.0"
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION_PREFIX=${VERSION}" >> $GITHUB_ENV
else
BRANCH=${GITHUB_REF#refs/heads/} BRANCH=${GITHUB_REF#refs/heads/}
if [[ $BRANCH =~ ^[0-9] ]]; then if [[ $BRANCH =~ ^[0-9]\.x$ ]]; then
# Branches starting with %d.x need to use -dev suffix
echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV
else else
# All other branches use dev-${branch} prefix
echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV
fi fi
fi
- name: Clone test suite - name: Clone test suite
run: git clone https://github.com/archtechx/tenancy-queue-tester run: git clone https://github.com/archtechx/tenancy-queue-tester
@ -25,3 +33,5 @@ jobs:
cd tenancy-queue-tester cd tenancy-queue-tester
TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh
./test.sh ./test.sh
./alternative_config.sh
PERSISTENT=1 ./test.sh

View file

@ -132,6 +132,7 @@ $finder = Finder::create()
->in([ ->in([
$project_path . '/src', $project_path . '/src',
]) ])
->exclude('Enums')
->name('*.php') ->name('*.php')
->notName('*.blade.php') ->notName('*.blade.php')
->ignoreDotFiles(true) ->ignoreDotFiles(true)

129
CLAUDE.md Normal file
View file

@ -0,0 +1,129 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Testing
- `composer test` - Run tests without coverage using Docker
- `./test tests/TestFile.php` - Run an entire test file
- `./t 'test name'` - Run a specific test
- You can append `-v` to get a full stack trace if a test fails due to an exception
### Code Quality
- `composer phpstan` - Run PHPStan static analysis (level 8)
- `composer cs` - Fix code style using PHP CS Fixer
### Docker Development
- `composer docker-up` - Start Docker environment
- `composer docker-down` - Stop Docker environment
- `composer docker-restart` - Restart Docker environment
## Architecture Overview
**Tenancy for Laravel** is a multi-tenancy package that automatically handles tenant isolation without requiring changes to application code.
### Core Components
**Central Classes:**
- `Tenancy` - Main orchestrator class managing tenant context and lifecycle
- `TenancyServiceProvider` (NOT the stub) - Registers services, commands, and bootstrappers
- `Tenant` (model) - Represents individual tenants with domains and databases
- `Domain` (model) - Maps domains/subdomains to tenants
**Tenant Identification:**
- **Resolvers** (`src/Resolvers/`) - Identify tenants by domain, path, or request data - this data comes from middleware
- **Middleware** (`src/Middleware/`) - Middleware that calls resolvers and tries to initialize tenancy based on information from a request
- **Cached resolvers** - Cached wrapper around resolvers to avoid querying the central database
**Tenancy Bootstrappers (`src/Bootstrappers/`):**
- `DatabaseTenancyBootstrapper` - Switches database connections
- `CacheTenancyBootstrapper` - Isolates cache by tenant
- `FilesystemTenancyBootstrapper` - Manages tenant-specific storage
- `QueueTenancyBootstrapper` - Ensures queued jobs run in correct tenant context
- `RedisTenancyBootstrapper` - Prefixes Redis keys by tenant
**Database Management:**
- **DatabaseManager** - Creates/deletes tenant databases and users
- **TenantDatabaseManagers** - Database-specific implementations (MySQL, PostgreSQL, SQLite, SQL Server)
- **Row Level Security (RLS)** - PostgreSQL-based tenant isolation using policies
**Advanced Features:**
- **Resource Syncing** - Sync central models to tenant databases
- **User Impersonation** - Admin access to tenant contexts
- **Cross-domain redirects** - Handle multi-domain tenant setups
- **Telescope integration** - Tag entries by tenant
### Key Patterns
**Tenant Context Management:**
```php
tenancy()->initialize($tenant); // Switch to tenant
tenancy()->run($tenant, $callback); // Atomic tenant execution
tenancy()->runForMultiple($tenants, $callback); // Batch operations
tenancy()->central($callback); // Run in central context
```
**Tenant Identification Flow:**
1. Middleware identifies tenant from request (domain/subdomain/path)
2. Resolver fetches tenant model from identification data
3. Tenancy initializes and bootstrappers configure tenant context
4. Application runs with tenant-specific database/cache/storage
**Route Middleware Groups:**
All of these work as flags, i.e. middleware groups that are empty arrays with a purely semantic use.
- `tenant` - Routes requiring tenant context
- `central` - Routes for central/admin functionality
- `universal` - Routes working in both contexts
- `clone` - Tells route cloning logic to clone the route
### Early Identification
**Early identification** ensures tenancy is initialized before controller instantiation, which is critical for certain scenarios.
**When needed:**
- Controllers using constructor dependency injection
- Integration with packages that inject dependencies in constructors
**The Problem:**
Laravel executes controller constructors and route model binding before route-level middleware runs, causing services to use central context instead of tenant context.
**Solutions:**
1. **Avoid Constructor Injection** - Use method injection instead
2. **Laravel's Native Solution** - Use controllers that implement `HasMiddleware` interface
3. **Kernel Identification** - Add middleware to HTTP Kernel's global stack:
```php
// In HttpKernel.php
protected $middleware = [
\Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
// other middleware...
];
```
Note you also need to flag the route with the `'tenant'` middleware if default route mode (set in config) isn't set to TENANT.
**Benefits:**
- Constructor dependency injection receives tenant-aware services
- Seamless integration with existing Laravel applications
### Testing Environment
Tests use Docker with MySQL/PostgreSQL/Redis. The `./test` script runs Pest tests inside containers with proper database isolation.
`./t 'test name'` is equivalent to `./test --filter 'test name'`
**Key test patterns:**
- Database preparation and cleanup between tests
- Multi-database scenarios (central + tenant databases)
- Middleware and identification testing
- Resource syncing validation
### Configuration
Central config in `config/tenancy.php` controls:
- Tenant/domain model classes
- Database connection settings
- Enabled bootstrappers and features
- Identification middleware and resolvers
- Cache and storage prefixes

View file

@ -24,10 +24,12 @@ To fix this, simply delete the database memory by shutting down containers and s
Same thing for `SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared`. Same thing for `SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared`.
### Docker on M1 ### Docker on Apple Silicon
Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1. Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1.
2025 note: By now only MSSQL doesn't have good M1 support. The override also started being a bit problematic, having issues with starts, often requiring multiple starts. This often makes the original image in docker-compose more stable, even if it's amd64-only. With Rosetta enabled, you should be able to use it without issues.
### Coverage reports ### Coverage reports
To run tests and generate coverage reports, use `composer test-full`. To run tests and generate coverage reports, use `composer test-full`.

View file

@ -53,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider
Events\DeletingTenant::class => [ Events\DeletingTenant::class => [
JobPipeline::make([ JobPipeline::make([
Jobs\DeleteDomains::class, Jobs\DeleteDomains::class,
// Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\DeletingTenant $event) { ])->send(function (Events\DeletingTenant $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), })->shouldBeQueued(false),
@ -62,7 +63,6 @@ class TenancyServiceProvider extends ServiceProvider
Events\TenantDeleted::class => [ Events\TenantDeleted::class => [
JobPipeline::make([ JobPipeline::make([
Jobs\DeleteDatabase::class, Jobs\DeleteDatabase::class,
// Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
@ -145,24 +145,22 @@ class TenancyServiceProvider extends ServiceProvider
*/ */
protected function overrideUrlInTenantContext(): void protected function overrideUrlInTenantContext(): void
{ {
/** // \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) {
* Import your tenant model! // $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant
* // ? $tenant->domain
* \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { // : $tenant->domains->first()->domain;
* $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant //
* ? $tenant->domain // $scheme = str($originalRootUrl)->before('://');
* : $tenant->domains->first()->domain; //
* // if (str_contains($tenantDomain, '.')) {
* $scheme = str($originalRootUrl)->before('://'); // // Domain identification
* // return $scheme . '://' . $tenantDomain . '/';
* // If you're using domain identification: // } else {
* return $scheme . '://' . $tenantDomain . '/'; // // Subdomain identification
* // $originalDomain = str($originalRootUrl)->after($scheme . '://')->before('/');
* // If you're using subdomain identification: // return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/';
* $originalDomain = str($originalRootUrl)->after($scheme . '://'); // }
* return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; // };
* };
*/
} }
public function register() public function register()
@ -178,32 +176,17 @@ class TenancyServiceProvider extends ServiceProvider
$this->makeTenancyMiddlewareHighestPriority(); $this->makeTenancyMiddlewareHighestPriority();
$this->overrideUrlInTenantContext(); $this->overrideUrlInTenantContext();
/** // // Include soft deleted resources in synced resource queries.
* Include soft deleted resources in synced resource queries. // ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
* // if ($query->hasMacro('withTrashed')) {
* ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { // $query->withTrashed();
* if ($query->hasMacro('withTrashed')) {
* $query->withTrashed();
* }
* };
*/
/**
* To make Livewire v3 work with Tenancy, make the update route universal.
*
* Livewire::setUpdateRoute(function ($handle) {
* return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal']);
* });
*/
// if (InitializeTenancyByRequestData::inGlobalStack()) {
// FortifyRouteBootstrapper::$fortifyHome = 'dashboard';
// TenancyUrlGenerator::$prefixRouteNames = false;
// } // }
// };
if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) { // // To make Livewire v3 work with Tenancy, make the update route universal.
TenancyUrlGenerator::$prefixRouteNames = true; // Livewire::setUpdateRoute(function ($handle) {
} // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]);
// });
} }
protected function bootEvents() protected function bootEvents()
@ -228,15 +211,14 @@ class TenancyServiceProvider extends ServiceProvider
->group(base_path('routes/tenant.php')); ->group(base_path('routes/tenant.php'));
} }
// Delete this condition when using route-level path identification // $this->cloneRoutes();
if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) {
$this->cloneRoutes();
}
}); });
} }
/** /**
* Clone universal routes as tenant. * Clone routes as tenant.
*
* This is used primarily for integrating packages.
* *
* @see CloneRoutesAsTenant * @see CloneRoutesAsTenant
*/ */
@ -245,16 +227,23 @@ class TenancyServiceProvider extends ServiceProvider
/** @var CloneRoutesAsTenant $cloneRoutes */ /** @var CloneRoutesAsTenant $cloneRoutes */
$cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class);
/** // The cloning action has two modes:
* You can provide a closure for cloning a specific route, e.g.: // 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property.
* $cloneRoutes->cloneUsing('welcome', function () { // You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action.
* RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) //
* ->middleware(['universal', InitializeTenancyByPath::class]) // By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle()
* ->name('tenant.welcome'); // will clone all routes that have either 'clone' or 'universal' middleware (mentioning 'universal' since that's a common use case).
* }); //
* // Also, you can use the shouldClone() method to provide a custom closure that determines if a route should be cloned.
* To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. //
*/ // 2. Clone only the routes that were manually added to the action using cloneRoute().
//
// Regardless of the mode, you can provide a custom closure for defining the cloned route, e.g.:
// $cloneRoutesAction->cloneUsing(function (Route $route) {
// RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName());
// })->handle();
// This will make all cloned routes use the custom closure to define the cloned route instead of the default behavior.
// See Stancl\Tenancy\Actions\CloneRoutesAsTenant for more details.
$cloneRoutes->handle(); $cloneRoutes->handle();
} }

View file

@ -15,6 +15,7 @@ return [
'models' => [ 'models' => [
'tenant' => Stancl\Tenancy\Database\Models\Tenant::class, 'tenant' => Stancl\Tenancy\Database\Models\Tenant::class,
'domain' => Stancl\Tenancy\Database\Models\Domain::class, 'domain' => Stancl\Tenancy\Database\Models\Domain::class,
'impersonation_token' => Stancl\Tenancy\Database\Models\ImpersonationToken::class,
/** /**
* Name of the column used to relate models to tenants. * Name of the column used to relate models to tenants.
@ -33,6 +34,7 @@ return [
* *
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator
*/ */
'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class, 'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class,
@ -90,7 +92,7 @@ return [
/** /**
* Identification middleware tenancy recognizes as path identification middleware. * Identification middleware tenancy recognizes as path identification middleware.
* *
* This is used during determining whether whether a path identification is used * This is used for determining if a path identification middleware is used
* during operations specific to path identification, e.g. forgetting the tenant parameter in ForgetTenantParameter. * during operations specific to path identification, e.g. forgetting the tenant parameter in ForgetTenantParameter.
* *
* If you're using a custom path identification middleware, add it here. * If you're using a custom path identification middleware, add it here.
@ -117,6 +119,7 @@ return [
Resolvers\PathTenantResolver::class => [ Resolvers\PathTenantResolver::class => [
'tenant_parameter_name' => 'tenant', 'tenant_parameter_name' => 'tenant',
'tenant_model_column' => null, // null = tenant key 'tenant_model_column' => null, // null = tenant key
'tenant_route_name_prefix' => 'tenant.',
'allowed_extra_model_columns' => [], // used with binding route fields 'allowed_extra_model_columns' => [], // used with binding route fields
'cache' => false, 'cache' => false,
@ -124,13 +127,18 @@ return [
'cache_store' => null, // null = default 'cache_store' => null, // null = default
], ],
Resolvers\RequestDataTenantResolver::class => [ Resolvers\RequestDataTenantResolver::class => [
// Set any of these to null to disable that method of identification
'header' => 'X-Tenant',
'cookie' => 'tenant',
'query_parameter' => 'tenant',
'tenant_model_column' => null, // null = tenant key
'cache' => false, 'cache' => false,
'cache_ttl' => 3600, // seconds 'cache_ttl' => 3600, // seconds
'cache_store' => null, // null = default 'cache_store' => null, // null = default
], ],
], ],
// todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware()
], ],
/** /**
@ -214,7 +222,14 @@ return [
// 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager::class, // Also permission controlled // 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager::class, // Also permission controlled
], ],
// todo@docblock /*
* Drop tenant databases when `php artisan migrate:fresh` is used.
* You may want to use this locally since deleting tenants only
* deletes their databases when they're deleted individually, not
* when the records are mass deleted from the database.
*
* Note: This overrides the default MigrateFresh command.
*/
'drop_tenant_databases_on_migrate_fresh' => false, 'drop_tenant_databases_on_migrate_fresh' => false,
], ],
@ -319,7 +334,6 @@ return [
*/ */
'url_override' => [ 'url_override' => [
// Note that the local disk you add must exist in the tenancy.filesystem.root_override config // Note that the local disk you add must exist in the tenancy.filesystem.root_override config
// todo@v4 Rename url_override to something that describes the config key better
'public' => 'public-%tenant%', 'public' => 'public-%tenant%',
], ],
@ -355,7 +369,7 @@ return [
* leave asset() helper tenancy disabled and explicitly use tenant_asset() calls in places * leave asset() helper tenancy disabled and explicitly use tenant_asset() calls in places
* where you want to use tenant-specific assets (product images, avatars, etc). * where you want to use tenant-specific assets (product images, avatars, etc).
*/ */
'asset_helper_tenancy' => false, // todo@rename asset_helper_override? 'asset_helper_override' => false,
], ],
/** /**

View file

@ -18,26 +18,22 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^10.1|^11.3", "illuminate/support": "^12.0",
"laravel/tinker": "^2.0", "laravel/tinker": "^2.0",
"facade/ignition-contracts": "^1.0.2",
"spatie/ignition": "^1.4",
"ramsey/uuid": "^4.7.3", "ramsey/uuid": "^4.7.3",
"stancl/jobpipeline": "2.0.0-rc2", "stancl/jobpipeline": "2.0.0-rc5",
"stancl/virtualcolumn": "dev-master", "stancl/virtualcolumn": "^1.5.0",
"spatie/invade": "^1.1", "spatie/invade": "*",
"laravel/prompts": "0.*" "laravel/prompts": "0.*"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^10.1|^11.3", "laravel/framework": "^12.0",
"orchestra/testbench": "^8.0|^9.0", "orchestra/testbench": "^10.0",
"league/flysystem-aws-s3-v3": "^3.12.2", "league/flysystem-aws-s3-v3": "^3.12.2",
"doctrine/dbal": "^3.6.0", "doctrine/dbal": "^3.6.0",
"spatie/valuestore": "^1.2.5", "spatie/valuestore": "^1.2.5",
"pestphp/pest": "^2.0", "pestphp/pest": "^3.0",
"larastan/larastan": "^3.0", "larastan/larastan": "^3.0"
"spatie/invade": "^1.1",
"aws/aws-sdk-php-laravel": "~3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

View file

@ -1,9 +1,3 @@
services: services:
mysql:
# platform: linux/amd64 # either one works
image: arm64v8/mysql
mysql2:
# platform: linux/amd64 # either one works
image: arm64v8/mysql
mssql: mssql:
image: mcr.microsoft.com/azure-sql-edge image: mcr.microsoft.com/azure-sql-edge

View file

@ -33,7 +33,7 @@ services:
stdin_open: true stdin_open: true
tty: true tty: true
mysql: mysql:
image: mysql:5.7 image: mysql:8
environment: environment:
MYSQL_ROOT_PASSWORD: password MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: main MYSQL_DATABASE: main
@ -46,7 +46,7 @@ services:
tmpfs: tmpfs:
- /var/lib/mysql - /var/lib/mysql
mysql2: mysql2:
image: mysql:5.7 image: mysql:8
environment: environment:
MYSQL_ROOT_PASSWORD: password MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: main MYSQL_DATABASE: main
@ -72,12 +72,12 @@ services:
tmpfs: tmpfs:
- /var/lib/postgresql/data - /var/lib/postgresql/data
mssql: mssql:
image: mcr.microsoft.com/mssql/server:2019-latest image: mcr.microsoft.com/mssql/server:2022-latest
environment: environment:
- ACCEPT_EULA=Y - ACCEPT_EULA=Y
- SA_PASSWORD=P@ssword # todo reuse env from above - SA_PASSWORD=P@ssword # todo reuse env from above
healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432
test: timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
interval: 10s interval: 10s
timeout: 10s timeout: 10s
retries: 10 retries: 10

View file

@ -1,6 +1,5 @@
includes: includes:
- ./vendor/larastan/larastan/extension.neon - ./vendor/larastan/larastan/extension.neon
- ./vendor/spatie/invade/phpstan-extension.neon
parameters: parameters:
paths: paths:
@ -16,6 +15,12 @@ parameters:
ignoreErrors: ignoreErrors:
- identifier: trait.unused - identifier: trait.unused
- identifier: missingType.iterableValue - identifier: missingType.iterableValue
-
message: '#Spatie\\Invade\\Invader#'
identifier: method.notFound
-
message: '#Spatie\\Invade\\Invader#'
identifier: property.notFound
- '#FFI#' - '#FFI#'
- '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#'
- '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#'

View file

@ -7,40 +7,72 @@ namespace Stancl\Tenancy\Actions;
use Closure; use Closure;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Routing\Router; use Illuminate\Routing\Router;
use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
/** /**
* The CloneRoutesAsTenant action clones * Clones either all existing routes for which shouldBeCloned() returns true
* routes flagged with the 'universal' middleware, * (by default, all routes with any middleware present in $cloneRoutesWithMiddleware),
* all routes without a flag if the default route mode is universal, * or if any routes were manually added to $routesToClone using $action->cloneRoute($route),
* and routes that directly use the InitializeTenancyByPath middleware. * clone just the routes in $routesToClone. This means that only the routes specified
* by cloneRoute() (which can be chained infinitely -- you can specify as many routes as you want)
* will be cloned.
* *
* The main purpose of this action is to make the integration * The main purpose of this action is to make the integration of packages
* of packages (e.g., Jetstream or Livewire) easier with path-based tenant identification. * (e.g., Jetstream or Livewire) easier with path-based tenant identification.
* *
* By default, universal routes are cloned as tenant routes (= they get flagged with the 'tenant' middleware) * The default for $cloneRoutesWithMiddleware is ['clone'].
* and prefixed with the '/{tenant}' path prefix. Their name also gets prefixed with the tenant name prefix. * If $routesToClone is empty, all routes with any middleware specified in $cloneRoutesWithMiddleware will be cloned.
* The middleware can be in a group, nested as deep as you want
* (e.g. if a route has a 'foo' middleware which is a group containing the 'clone' middleware, the route will be cloned).
* *
* Routes with the path identification middleware get cloned similarly, but only if they're not universal at the same time. * You may customize $cloneRoutesWithMiddleware using cloneRoutesWithMiddleware() to make any middleware of your choice trigger cloning.
* Unlike universal routes, these routes don't get the tenant flag, * By providing a callback to shouldClone(), you can change how it's determined if a route should be cloned if you don't want to use middleware flags.
* because they don't need it (they're not universal, and they have the identification MW, so they're already considered tenant).
* *
* You can use the `cloneUsing()` hook to customize the route definitions, * Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'.
* and the `skipRoute()` method to skip cloning of specific routes. * The parameter name and prefix can be changed e.g. to `/{team}` and `team.` by configuring the path resolver (tenantParameterName and tenantRouteNamePrefix).
* You can also use the $tenantParameterName and $tenantRouteNamePrefix * Routes with names that are already prefixed won't be cloned - but that's just the default behavior.
* static properties to customize the tenant parameter name or the route name prefix. * The cloneUsing() method allows you to completely override the default behavior and customize how the cloned routes will be defined.
* *
* Note that routes already containing the tenant parameter or prefix won't be cloned. * After cloning, only top-level middleware in $cloneRoutesWithMiddleware will be removed
* from the new route (so by default, 'clone' will be omitted from the new route's MW).
* Middleware groups are preserved as-is, even if they contain cloning middleware.
*
* Routes that already contain the tenant parameter or have names with the tenant prefix
* will not be cloned.
*
* Example usage:
* ```
* Route::get('/foo', fn () => true)->name('foo')->middleware('clone');
* Route::get('/bar', fn () => true)->name('bar')->middleware('universal');
*
* $cloneAction = app(CloneRoutesAsTenant::class);
*
* // Clone foo route as /{tenant}/foo/ and name it tenant.foo ('clone' middleware won't be present in the cloned route)
* $cloneAction->handle();
*
* // Clone bar route as /{tenant}/bar and name it tenant.bar ('universal' middleware won't be present in the cloned route)
* $cloneAction->cloneRoutesWithMiddleware(['universal'])->handle();
*
* Route::get('/baz', fn () => true)->name('baz');
*
* // Clone baz route as /{tenant}/bar and name it tenant.baz ('universal' middleware won't be present in the cloned route)
* $cloneAction->cloneRoute('baz')->handle();
* ```
*
* Calling handle() will also clear the $routesToClone array.
* This means that $action->cloneRoute('foo')->handle() will clone the 'foo' route, but subsequent calls to handle() will behave
* as if cloneRoute() wasn't called at all ($routesToClone will be empty).
* Note that calling handle() does not reset the other properties.
*
* @see Stancl\Tenancy\Resolvers\PathTenantResolver
*/ */
class CloneRoutesAsTenant class CloneRoutesAsTenant
{ {
protected array $cloneRouteUsing = []; protected array $routesToClone = [];
protected array $skippedRoutes = [ protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
'stancl.tenancy.asset', protected Closure|null $shouldClone = null;
]; protected array $cloneRoutesWithMiddleware = ['clone'];
public function __construct( public function __construct(
protected Router $router, protected Router $router,
@ -48,100 +80,77 @@ class CloneRoutesAsTenant
public function handle(): void public function handle(): void
{ {
$this->getRoutesToClone()->each(fn (Route $route) => $this->cloneRoute($route)); // If no routes were specified using cloneRoute(), get all routes
// and for each, determine if it should be cloned
if (! $this->routesToClone) {
$this->routesToClone = collect($this->router->getRoutes()->get())
->filter(fn (Route $route) => $this->shouldBeCloned($route))
->all();
}
foreach ($this->routesToClone as $route) {
// If the cloneUsing callback is set,
// use the callback to clone the route instead of the default
if ($this->cloneUsing) {
($this->cloneUsing)($route);
continue;
}
if (is_string($route)) {
$this->router->getRoutes()->refreshNameLookups();
$route = $this->router->getRoutes()->getByName($route);
}
$this->copyMiscRouteProperties($route, $this->createNewRoute($route));
}
// Clean up the routesToClone array after cloning so that subsequent calls aren't affected
$this->routesToClone = [];
$this->router->getRoutes()->refreshNameLookups(); $this->router->getRoutes()->refreshNameLookups();
} }
/** public function cloneUsing(Closure|null $cloneUsing): static
* Make the action clone a specific route using the provided callback instead of the default one.
*/
public function cloneUsing(string $routeName, Closure $callback): static
{ {
$this->cloneRouteUsing[$routeName] = $callback; $this->cloneUsing = $cloneUsing;
return $this; return $this;
} }
/** public function cloneRoutesWithMiddleware(array $middleware): static
* Skip a route's cloning.
*/
public function skipRoute(string $routeName): static
{ {
$this->skippedRoutes[] = $routeName; $this->cloneRoutesWithMiddleware = $middleware;
return $this; return $this;
} }
/** public function shouldClone(Closure|null $shouldClone): static
* @return Collection<int, Route>
*/
protected function getRoutesToClone(): Collection
{ {
$tenantParameterName = PathTenantResolver::tenantParameterName(); $this->shouldClone = $shouldClone;
/** return $this;
* Clone all routes that: }
* - don't have the tenant parameter
* - aren't in the $skippedRoutes array public function cloneRoute(Route|string $route): static
* - are using path identification (kernel or route-level). {
* $this->routesToClone[] = $route;
* Non-universal cloned routes will only be available in the tenant context,
* universal routes will be available in both contexts. return $this;
*/ }
return collect($this->router->getRoutes()->get())->filter(function (Route $route) use ($tenantParameterName) {
if ( protected function shouldBeCloned(Route $route): bool
tenancy()->routeHasMiddleware($route, 'tenant') || {
in_array($route->getName(), $this->skippedRoutes, true) || // Don't clone routes that already have tenant parameter or prefix
in_array($tenantParameterName, $route->parameterNames(), true) if ($this->routeIsTenant($route)) {
) {
return false; return false;
} }
$pathIdentificationMiddleware = config('tenancy.identification.path_identification_middleware'); if ($this->shouldClone) {
$routeHasPathIdentificationMiddleware = tenancy()->routeHasMiddleware($route, $pathIdentificationMiddleware); return ($this->shouldClone)($route);
$routeHasNonPathIdentificationMiddleware = tenancy()->routeHasIdentificationMiddleware($route) && ! $routeHasPathIdentificationMiddleware;
$pathIdentificationMiddlewareInGlobalStack = tenancy()->globalStackHasMiddleware($pathIdentificationMiddleware);
/**
* The route should get cloned if:
* - it has route-level path identification middleware, OR
* - it uses kernel path identification (it doesn't have any route-level identification middleware) and the route is tenant or universal.
*
* The route is considered tenant if:
* - it's flagged as tenant, OR
* - it's not flagged as tenant or universal, but it has the identification middleware
*
* The route is considered universal if it's flagged as universal, and it doesn't have the tenant flag
* (it's still considered universal if it has route-level path identification middleware + the universal flag).
*
* If the route isn't flagged, the context is determined using the default route mode.
*/
$pathIdentificationUsed = (! $routeHasNonPathIdentificationMiddleware) &&
($routeHasPathIdentificationMiddleware || $pathIdentificationMiddlewareInGlobalStack);
return $pathIdentificationUsed &&
(tenancy()->getRouteMode($route) === RouteMode::UNIVERSAL || tenancy()->routeHasMiddleware($route, 'clone'));
});
} }
/** return tenancy()->routeHasMiddleware($route, $this->cloneRoutesWithMiddleware);
* Clone a route using a callback specified in the $cloneRouteUsing property (using the cloneUsing method).
* If there's no callback specified for the route, use the default way of cloning routes.
*/
protected function cloneRoute(Route $route): void
{
$routeName = $route->getName();
// If the route's cloning callback exists
// Use the callback to clone the route instead of the default way of cloning routes
if ($routeName && $customRouteCallback = data_get($this->cloneRouteUsing, $routeName)) {
$customRouteCallback($route);
return;
}
$this->copyMiscRouteProperties($route, $this->createNewRoute($route));
} }
protected function createNewRoute(Route $route): Route protected function createNewRoute(Route $route): Route
@ -150,33 +159,24 @@ class CloneRoutesAsTenant
$prefix = trim($route->getPrefix() ?? '', '/'); $prefix = trim($route->getPrefix() ?? '', '/');
$uri = $route->getPrefix() ? Str::after($route->uri(), $prefix) : $route->uri(); $uri = $route->getPrefix() ? Str::after($route->uri(), $prefix) : $route->uri();
$newRouteAction = collect($route->action)->tap(function (Collection $action) use ($route, $prefix) { $action = collect($route->action);
/** @var array $routeMiddleware */
$routeMiddleware = $action->get('middleware') ?? [];
// Make the new route have the same middleware as the original route // Make the new route have the same middleware as the original route
// Add the 'tenant' middleware to the new route // Add the 'tenant' middleware to the new route
// Exclude `universal` and `clone` middleware from the new route (it should only be flagged as tenant) // Exclude $this->cloneRoutesWithMiddleware MW from the new route (it should only be flagged as tenant)
$newRouteMiddleware = collect($routeMiddleware)
->merge(['tenant']) // Add 'tenant' flag
->filter(fn (string $middleware) => ! in_array($middleware, ['universal', 'clone']))
->toArray();
$tenantRouteNamePrefix = PathTenantResolver::tenantRouteNamePrefix(); $middleware = $this->processMiddlewareForCloning($action->get('middleware') ?? []);
// Make sure the route name has the tenant route name prefix if ($name = $route->getName()) {
$newRouteNamePrefix = $route->getName() $action->put('as', PathTenantResolver::tenantRouteNamePrefix() . $name);
? $tenantRouteNamePrefix . Str::after($route->getName(), $tenantRouteNamePrefix) }
: null;
return $action $action
->put('as', $newRouteNamePrefix) ->put('middleware', $middleware)
->put('middleware', $newRouteMiddleware)
->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); ->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}');
})->toArray();
/** @var Route $newRoute */ /** @var Route $newRoute */
$newRoute = $this->router->$method($uri, $newRouteAction); $newRoute = $this->router->$method($uri, $action->toArray());
return $newRoute; return $newRoute;
} }
@ -194,4 +194,26 @@ class CloneRoutesAsTenant
->withTrashed($originalRoute->allowsTrashedBindings()) ->withTrashed($originalRoute->allowsTrashedBindings())
->setDefaults($originalRoute->defaults); ->setDefaults($originalRoute->defaults);
} }
/** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */
protected function processMiddlewareForCloning(array $middleware): array
{
$processedMiddleware = array_filter(
$middleware,
fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware)
);
$processedMiddleware[] = 'tenant';
return array_unique($processedMiddleware);
}
/** Check if route already has tenant parameter or name prefix. */
protected function routeIsTenant(Route $route): bool
{
$routeHasTenantParameter = in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames());
$routeHasTenantPrefix = $route->getName() && str_starts_with($route->getName(), PathTenantResolver::tenantRouteNamePrefix());
return $routeHasTenantParameter || $routeHasTenantPrefix;
}
} }

View file

@ -92,7 +92,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
protected function assetHelper(string|false $suffix): void protected function assetHelper(string|false $suffix): void
{ {
if (! $this->app['config']['tenancy.filesystem.asset_helper_tenancy']) { if (! $this->app['config']['tenancy.filesystem.asset_helper_override']) {
return; return;
} }

View file

@ -7,54 +7,63 @@ namespace Stancl\Tenancy\Bootstrappers\Integrations;
use Illuminate\Config\Repository; use Illuminate\Config\Repository;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Enums\Context;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
/** /**
* Allows customizing Fortify action redirects * Allows customizing Fortify action redirects so that they can also redirect
* so that they can also redirect to tenant routes instead of just the central routes. * to tenant routes instead of just the central routes.
* *
* Works with path and query string identification. * This should be used with path/query string identification OR when using Fortify
* universally, including with domains.
*
* When using domain identification, there's no need to pass the tenant parameter,
* you only want to customize the routes being used, so you can set $passTenantParameter
* to false.
*/ */
class FortifyRouteBootstrapper implements TenancyBootstrapper class FortifyRouteBootstrapper implements TenancyBootstrapper
{ {
/** /**
* Make Fortify actions redirect to custom routes. * Fortify redirects that should be used in tenant context.
* *
* For each route redirect, specify the intended route context (central or tenant). * Syntax: ['redirect_name' => 'tenant_route_name']
* Based on the provided context, we pass the tenant parameter to the route (or not).
* The tenant parameter is only passed to the route when you specify its context as tenant.
*
* The route redirects should be in the following format:
*
* 'fortify_action' => [
* 'route_name' => 'tenant.route',
* 'context' => Context::TENANT,
* ]
*
* For example:
*
* FortifyRouteBootstrapper::$fortifyRedirectMap = [
* // On logout, redirect the user to the "bye" route in the central app
* 'logout' => [
* 'route_name' => 'bye',
* 'context' => Context::CENTRAL,
* ],
*
* // On login, redirect the user to the "welcome" route in the tenant app
* 'login' => [
* 'route_name' => 'welcome',
* 'context' => Context::TENANT,
* ],
* ];
*/ */
public static array $fortifyRedirectMap = []; public static array $fortifyRedirectMap = [];
/**
* Should the tenant parameter be passed to fortify routes in the tenant context.
*
* This should be enabled with path/query string identification and disabled with domain identification.
*
* You may also disable this when using path/query string identification if passing the tenant parameter
* is handled in another way (TenancyUrlGenerator::$passTenantParameter for both,
* UrlGeneratorBootstrapper:$addTenantParameterToDefaults for path identification).
*/
public static bool $passTenantParameter = false;
/** /**
* Tenant route that serves as Fortify's home (e.g. a tenant dashboard route). * Tenant route that serves as Fortify's home (e.g. a tenant dashboard route).
* This route will always receive the tenant parameter. * This route will always receive the tenant parameter.
*/ */
public static string $fortifyHome = 'tenant.dashboard'; public static string|null $fortifyHome = 'tenant.dashboard';
/**
* Follow the query_parameter config instead of the tenant_parameter_name (path identification) config.
*
* This only has an effect when:
* - $passTenantParameter is enabled, and
* - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver.
*
* In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'),
* the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'.
*
* This is enabled by default because typically you will not need $passTenantParameter with path identification.
* UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification.
*
* On the other hand, when using request data identification (specifically query string) you WILL need to
* pass the parameter therefore you would use $passTenantParameter.
*/
public static bool $passQueryParameter = true;
protected array $originalFortifyConfig = []; protected array $originalFortifyConfig = [];
@ -76,27 +85,28 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper
protected function useTenantRoutesInFortify(Tenant $tenant): void protected function useTenantRoutesInFortify(Tenant $tenant): void
{ {
$tenantKey = $tenant->getTenantKey(); if (static::$passQueryParameter) {
// todo@tests
$tenantParameterName = RequestDataTenantResolver::queryParameterName();
$tenantParameterValue = RequestDataTenantResolver::payloadValue($tenant);
} else {
$tenantParameterName = PathTenantResolver::tenantParameterName(); $tenantParameterName = PathTenantResolver::tenantParameterName();
$tenantParameterValue = PathTenantResolver::tenantParameterValue($tenant);
}
$generateLink = function (array $redirect) use ($tenantKey, $tenantParameterName) { $generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) {
// Specifying the context is only required with query string identification return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []);
// because with path identification, the tenant parameter should always present
$passTenantParameter = $redirect['context'] === Context::TENANT;
// Only pass the tenant parameter when the user should be redirected to a tenant route
return route($redirect['route_name'], $passTenantParameter ? [$tenantParameterName => $tenantKey] : []);
}; };
// Get redirect URLs for the configured redirect routes // Get redirect URLs for the configured redirect routes
$redirects = array_merge( $redirects = array_merge(
$this->originalFortifyConfig['redirects'] ?? [], // Fortify config redirects $this->originalFortifyConfig['redirects'] ?? [], // Fortify config redirects
array_map(fn (array $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects array_map(fn (string $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects
); );
if (static::$fortifyHome) { if (static::$fortifyHome) {
// Generate the home route URL with the tenant parameter and make it the Fortify home route // Generate the home route URL with the tenant parameter and make it the Fortify home route
$this->config->set('fortify.home', route(static::$fortifyHome, [$tenantParameterName => $tenantKey])); $this->config->set('fortify.home', $generateLink(static::$fortifyHome));
} }
$this->config->set('fortify.redirects', $redirects); $this->config->set('fortify.redirects', $redirects);

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* Adds support for running queued tenant jobs in batches.
*
* @deprecated Doesn't seem to 1. be necessary, 2. work correctly in Laravel 11. Please don't use this bootstrapper, the class will be removed before release.
*/
class JobBatchBootstrapper implements TenancyBootstrapper
{
public function __construct(
protected Application $app,
) {}
public function bootstrap(Tenant $tenant): void
{
$this->deprecatedNotice();
}
protected function deprecatedNotice(): void
{
if ($this->app->environment() == 'local' && $this->app->hasDebugModeEnabled()) {
throw new Exception("JobBatchBootstrapper is not supported anymore, please remove it from your tenancy config. Job batches should work out of the box in Laravel 11. If they don't, please open a bug report.");
}
}
public function revert(): void
{
$this->deprecatedNotice();
}
}

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Events\JobRetryRequested;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Testing\Fakes\QueueFake;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class PersistentQueueTenancyBootstrapper implements TenancyBootstrapper
{
/** @var Repository */
protected $config;
/** @var QueueManager */
protected $queue;
/**
* The normal constructor is only executed after tenancy is bootstrapped.
* However, we're registering a hook to initialize tenancy. Therefore,
* we need to register the hook at service provider execution time.
*/
public static function __constructStatic(Application $app): void
{
static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
}
public function __construct(Repository $config, QueueManager $queue)
{
$this->config = $config;
$this->queue = $queue;
$this->setUpPayloadGenerator();
}
protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void
{
$previousTenant = null;
$dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) {
$previousTenant = tenant();
static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
});
$dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
$previousTenant = tenant();
static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
});
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls
$revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
if ($runningTests) {
static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant);
// We don't need to reset $previousTenant since the value will be set again when a job is processed.
}
// If we're not running tests, we remain in the tenant's context. This makes other JobProcessed
// listeners able to deserialize the job, including with SerializesModels, since the tenant connection
// remains open.
};
$dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
$dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
}
protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{
if (! $tenantId) {
// The job is not tenant-aware
if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context
tenancy()->end();
}
return;
}
// Re-initialize tenancy between all jobs even if the tenant is the same
// so that we don't work with an outdated tenant() instance in case it
// was updated outside the queue worker.
tenancy()->end();
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
}
protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void
{
// The job was not tenant-aware
if (! $tenantId) {
return;
}
// Revert back to the previous tenant
if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) {
tenancy()->initialize($previousTenant);
}
// End tenancy
if (tenant() && (! $previousTenant)) {
tenancy()->end();
}
}
protected function setUpPayloadGenerator(): void
{
$bootstrapper = &$this;
if (! $this->queue instanceof QueueFake) {
$this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
return $bootstrapper->getPayload($connection);
});
}
}
public function getPayload(string $connection): array
{
if (! tenancy()->initialized) {
return [];
}
if ($this->config["queue.connections.$connection.central"]) {
return [];
}
return [
'tenant_id' => tenant()->getTenantKey(),
];
}
public function bootstrap(Tenant $tenant): void {}
public function revert(): void {}
}

View file

@ -24,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
/** @var QueueManager */ /** @var QueueManager */
protected $queue; protected $queue;
/**
* Don't persist the same tenant across multiple jobs even if they have the same tenant ID.
*
* This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again
* with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases.
*
* @var bool
*/
public static $forceRefresh = false;
/** /**
* The normal constructor is only executed after tenancy is bootstrapped. * The normal constructor is only executed after tenancy is bootstrapped.
* However, we're registering a hook to initialize tenancy. Therefore, * However, we're registering a hook to initialize tenancy. Therefore,
@ -68,9 +58,12 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
}); });
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls
$revertToPreviousState = function ($event) use (&$previousTenant) { $revertToPreviousState = function ($event) use (&$previousTenant) {
static::revertToPreviousState($event, $previousTenant); // In queue worker context, this reverts to the central context.
// In dispatchSync context, this reverts to the previous tenant's context.
// There's no need to reset $previousTenant here since it's always first
// set in the above listeners and the app is reverted back to that context.
static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant);
}; };
$dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
@ -79,61 +72,25 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
protected static function initializeTenancyForQueue(string|int|null $tenantId): void protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{ {
if ($tenantId === null) {
// The job is not tenant-aware
if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context
tenancy()->end();
}
return;
}
if (static::$forceRefresh) {
// Re-initialize tenancy between all jobs
if (tenancy()->initialized) {
tenancy()->end();
}
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
return;
}
if (tenancy()->initialized) {
// Tenancy is already initialized
if (tenant()->getTenantKey() === $tenantId) {
// It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant)
return;
}
}
// Tenancy was either not initialized, or initialized for a different tenant.
// Therefore, we initialize it for the correct tenant.
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
}
protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void
{
$tenantId = $event->job->payload()['tenant_id'] ?? null;
// The job was not tenant-aware
if (! $tenantId) { if (! $tenantId) {
return; return;
} }
// Revert back to the previous tenant /** @var Tenant $tenant */
if (tenant() && $previousTenant?->isNot(tenant())) { $tenant = tenancy()->find($tenantId);
tenancy()->initialize($previousTenant); tenancy()->initialize($tenant);
} }
// End tenancy protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void
if (tenant() && (! $previousTenant)) { {
// The job was not tenant-aware so no context switch was done
if (! $tenantId) {
return;
}
// End tenancy when there's no previous tenant
// (= when running in a queue worker, not dispatchSync)
if (tenant() && ! $previousTenant) {
tenancy()->end(); tenancy()->end();
} }
} }
@ -149,16 +106,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
} }
} }
public function bootstrap(Tenant $tenant): void
{
//
}
public function revert(): void
{
//
}
public function getPayload(string $connection): array public function getPayload(string $connection): array
{ {
if (! tenancy()->initialized) { if (! tenancy()->initialized) {
@ -169,10 +116,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
return []; return [];
} }
$id = tenant()->getTenantKey();
return [ return [
'tenant_id' => $id, 'tenant_id' => tenant()->getTenantKey(),
]; ];
} }
public function bootstrap(Tenant $tenant): void {}
public function revert(): void {}
} }

View file

@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Bootstrappers;
use Closure; use Closure;
use Illuminate\Config\Repository; use Illuminate\Config\Repository;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
@ -36,28 +35,43 @@ class RootUrlBootstrapper implements TenancyBootstrapper
protected string|null $originalRootUrl = null; protected string|null $originalRootUrl = null;
/**
* Overriding the root url may cause issues in *some* tests, so you can disable
* the behavior by setting this property to false.
*/
public static bool $rootUrlOverrideInTests = true;
public function __construct( public function __construct(
protected UrlGenerator $urlGenerator,
protected Repository $config, protected Repository $config,
protected Application $app, protected Application $app,
) {} ) {}
public function bootstrap(Tenant $tenant): void public function bootstrap(Tenant $tenant): void
{ {
if ($this->app->runningInConsole() && static::$rootUrlOverride) { if (static::$rootUrlOverride === null) {
$this->originalRootUrl = $this->urlGenerator->to('/'); return;
}
if (! $this->app->runningInConsole()) {
return;
}
if ($this->app->runningUnitTests() && ! static::$rootUrlOverrideInTests) {
return;
}
$this->originalRootUrl = $this->app['url']->to('/');
$newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl); $newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl);
$this->urlGenerator->forceRootUrl($newRootUrl); $this->app['url']->forceRootUrl($newRootUrl);
$this->config->set('app.url', $newRootUrl); $this->config->set('app.url', $newRootUrl);
} }
}
public function revert(): void public function revert(): void
{ {
if ($this->originalRootUrl) { if ($this->originalRootUrl) {
$this->urlGenerator->forceRootUrl($this->originalRootUrl); $this->app['url']->forceRootUrl($this->originalRootUrl);
$this->config->set('app.url', $this->originalRootUrl); $this->config->set('app.url', $this->originalRootUrl);
} }
} }

View file

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
/** /**
* Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which: * Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which:
@ -19,10 +20,20 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
* Used with path and query string identification. * Used with path and query string identification.
* *
* @see TenancyUrlGenerator * @see TenancyUrlGenerator
* @see \Stancl\Tenancy\Resolvers\PathTenantResolver * @see PathTenantResolver
*/ */
class UrlGeneratorBootstrapper implements TenancyBootstrapper class UrlGeneratorBootstrapper implements TenancyBootstrapper
{ {
/**
* Should the tenant route parameter get added to TenancyUrlGenerator::defaults().
*
* This is recommended when using path identification since defaults() generally has better support in integrations,
* namely Ziggy, compared to TenancyUrlGenerator::$passTenantParameterToRoutes.
*
* With query string identification, this has no effect since URL::defaults() only works for route paramaters.
*/
public static bool $addTenantParameterToDefaults = true;
public function __construct( public function __construct(
protected Application $app, protected Application $app,
protected UrlGenerator $originalUrlGenerator, protected UrlGenerator $originalUrlGenerator,
@ -32,12 +43,12 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
{ {
URL::clearResolvedInstances(); URL::clearResolvedInstances();
$this->useTenancyUrlGenerator(); $this->useTenancyUrlGenerator($tenant);
} }
public function revert(): void public function revert(): void
{ {
$this->app->bind('url', fn () => $this->originalUrlGenerator); $this->app->extend('url', fn () => $this->originalUrlGenerator);
} }
/** /**
@ -45,16 +56,29 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
* *
* @see \Illuminate\Routing\RoutingServiceProvider registerUrlGenerator() * @see \Illuminate\Routing\RoutingServiceProvider registerUrlGenerator()
*/ */
protected function useTenancyUrlGenerator(): void protected function useTenancyUrlGenerator(Tenant $tenant): void
{ {
$this->app->extend('url', function (UrlGenerator $urlGenerator, Application $app) {
$newGenerator = new TenancyUrlGenerator( $newGenerator = new TenancyUrlGenerator(
$app['router']->getRoutes(), $this->app['router']->getRoutes(),
$urlGenerator->getRequest(), $this->originalUrlGenerator->getRequest(),
$app['config']->get('app.asset_url'), $this->app['config']->get('app.asset_url'),
); );
$newGenerator->defaults($urlGenerator->getDefaultParameters()); $defaultParameters = $this->originalUrlGenerator->getDefaultParameters();
if (static::$addTenantParameterToDefaults) {
$tenantParameterName = PathTenantResolver::tenantParameterName();
$defaultParameters = array_merge($defaultParameters, [
$tenantParameterName => PathTenantResolver::tenantParameterValue($tenant),
]);
foreach (PathTenantResolver::allowedExtraModelColumns() as $column) {
$defaultParameters["$tenantParameterName:$column"] = $tenant->getAttribute($column);
}
}
$newGenerator->defaults($defaultParameters);
$newGenerator->setSessionResolver(function () { $newGenerator->setSessionResolver(function () {
return $this->app['session'] ?? null; return $this->app['session'] ?? null;
@ -64,7 +88,6 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
return $this->app->make('config')->get('app.key'); return $this->app->make('config')->get('app.key');
}); });
return $newGenerator; $this->app->extend('url', fn () => $newGenerator);
});
} }
} }

View file

@ -22,6 +22,23 @@ class CreateUserWithRLSPolicies extends Command
protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet"; protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet";
/**
* Force, rather than just enable, the created RLS policies.
*
* By default, table owners bypass RLS policies. When this is enabled,
* they also need the BYPASSRLS permission. If your setup lets you create
* a user with BYPASSRLS, you may prefer leaving this on for additional
* safety. Otherwise, if you can't use BYPASSRLS, you can set this to false
* and depend on the behavior of table owners bypassing RLS automatically.
*
* This setting generally doesn't affect behavior at all with "default"
* setups, however if you have a more custom setup, with additional users
* involved (e.g. central connection user not being the same user that
* creates tables, or the created "RLS user" creating some tables) you
* should take care with how you configure this.
*/
public static bool $forceRls = true;
public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int
{ {
$username = config('tenancy.rls.user.username'); $username = config('tenancy.rls.user.username');
@ -49,15 +66,10 @@ class CreateUserWithRLSPolicies extends Command
// Enable RLS scoping on the table (without this, queries won't be scoped using RLS) // Enable RLS scoping on the table (without this, queries won't be scoped using RLS)
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY"); DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
/** if (static::$forceRls) {
* Force RLS scoping on the table, so that the table owner users
* don't bypass the scoping table owners bypass RLS by default.
*
* E.g. when using a custom implementation where you create tables as the RLS user,
* the queries won't be scoped for the RLS user unless we force the RLS scoping using this query.
*/
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY"); DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
} }
}
/** /**
* Create a DatabaseConfig instance for the RLS user, * Create a DatabaseConfig instance for the RLS user,

View file

@ -110,7 +110,7 @@ trait DealsWithRouteContexts
foreach ($middleware as $inner) { foreach ($middleware as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner])) { if (! $inner instanceof Closure && isset($middlewareGroups[$inner])) {
$innerMiddleware = Arr::wrap($middlewareGroups[$inner]); $innerMiddleware = array_merge($innerMiddleware, Arr::wrap($middlewareGroups[$inner]));
} }
} }

View file

@ -56,6 +56,6 @@ trait DealsWithTenantSymlinks
/** Determine if the provided path is an existing symlink. */ /** Determine if the provided path is an existing symlink. */
protected function symlinkExists(string $link): bool protected function symlinkExists(string $link): bool
{ {
return file_exists($link) && is_link($link); return is_link($link);
} }
} }

View file

@ -5,49 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Exception; use Exception;
use Spatie\ErrorSolutions\Contracts\BaseSolution;
use Spatie\ErrorSolutions\Contracts\ProvidesSolution;
abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution abstract class TenantCouldNotBeIdentifiedException extends Exception
{ {
/** Default solution title. */ protected function tenantCouldNotBeIdentified(string $how): void
protected string $solutionTitle = 'Tenant could not be identified';
/** Default solution description. */
protected string $solutionDescription = 'Are you sure this tenant exists?';
/** Set the message. */
protected function tenantCouldNotBeIdentified(string $how): static
{ {
$this->message = 'Tenant could not be identified ' . $how; $this->message = 'Tenant could not be identified ' . $how;
return $this;
}
/** Set the solution title. */
protected function title(string $solutionTitle): static
{
$this->solutionTitle = $solutionTitle;
return $this;
}
/** Set the solution description. */
protected function description(string $solutionDescription): static
{
$this->solutionDescription = $solutionDescription;
return $this;
}
/** Get the Ignition description. */
public function getSolution(): BaseSolution
{
return BaseSolution::create($this->solutionTitle)
->setSolutionDescription($this->solutionDescription)
->setDocumentationLinks([
'Tenants' => 'https://tenancyforlaravel.com/docs/v3/tenants',
'Tenant Identification' => 'https://tenancyforlaravel.com/docs/v3/tenant-identification',
]);
} }
} }

View file

@ -11,5 +11,5 @@ interface UniqueIdentifierGenerator
/** /**
* Generate a unique identifier for a model. * Generate a unique identifier for a model.
*/ */
public static function generate(Model $model): string; public static function generate(Model $model): string|int;
} }

View file

@ -10,16 +10,11 @@ trait CreatesDatabaseUsers
{ {
public function createDatabase(TenantWithDatabase $tenant): bool public function createDatabase(TenantWithDatabase $tenant): bool
{ {
parent::createDatabase($tenant); return parent::createDatabase($tenant) && $this->createUser($tenant->database());
return $this->createUser($tenant->database());
} }
public function deleteDatabase(TenantWithDatabase $tenant): bool public function deleteDatabase(TenantWithDatabase $tenant): bool
{ {
// Some DB engines require the user to be deleted before the database (e.g. Postgres) return $this->deleteUser($tenant->database()) && parent::deleteDatabase($tenant);
$this->deleteUser($tenant->database());
return parent::deleteDatabase($tenant);
} }
} }

View file

@ -23,6 +23,8 @@ use Stancl\Tenancy\Events\PullingPendingTenant;
*/ */
trait HasPending trait HasPending
{ {
public static string $pendingSinceCast = 'timestamp';
/** Boot the trait. */ /** Boot the trait. */
public static function bootHasPending(): void public static function bootHasPending(): void
{ {
@ -32,7 +34,7 @@ trait HasPending
/** Initialize the trait. */ /** Initialize the trait. */
public function initializeHasPending(): void public function initializeHasPending(): void
{ {
$this->casts['pending_since'] = 'timestamp'; $this->casts['pending_since'] = static::$pendingSinceCast;
} }
/** Determine if the model instance is in a pending state. */ /** Determine if the model instance is in a pending state. */

View file

@ -4,16 +4,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
trait InvalidatesResolverCache trait InvalidatesResolverCache
{ {
public static function bootInvalidatesResolverCache(): void public static function bootInvalidatesResolverCache(): void
{ {
static::saved(function (Tenant&Model $tenant) { static::saved(Tenancy::invalidateResolverCache(...));
Tenancy::invalidateResolverCache($tenant); static::deleting(Tenancy::invalidateResolverCache(...));
});
} }
} }

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns; namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
/** /**
@ -15,13 +14,9 @@ trait InvalidatesTenantsResolverCache
{ {
public static function bootInvalidatesTenantsResolverCache(): void public static function bootInvalidatesTenantsResolverCache(): void
{ {
static::saved(function (Model $model) { $invalidateCache = static fn (Model $model) => Tenancy::invalidateResolverCache($model->tenant);
foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */
$resolver = app($resolver);
$resolver->invalidateCache($model->tenant); static::saved($invalidateCache);
} static::deleting($invalidateCache);
});
} }
} }

View file

@ -20,7 +20,7 @@ class PendingScope implements Scope
/** /**
* Apply the scope to a given Eloquent query builder. * Apply the scope to a given Eloquent query builder.
* *
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<Model> $builder
* *
* @return void * @return void
*/ */

View file

@ -25,6 +25,9 @@ class ImpersonationToken extends Model
{ {
use CentralConnection; use CentralConnection;
/** You can set this property to customize the table name */
public static string $tableName = 'tenant_user_impersonation_tokens';
protected $guarded = []; protected $guarded = [];
public $timestamps = false; public $timestamps = false;
@ -33,11 +36,15 @@ class ImpersonationToken extends Model
public $incrementing = false; public $incrementing = false;
protected $table = 'tenant_user_impersonation_tokens';
protected $casts = [ protected $casts = [
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];
public function getTable()
{
return static::$tableName;
}
public static function booted(): void public static function booted(): void
{ {
static::creating(function ($model) { static::creating(function ($model) {

View file

@ -11,8 +11,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager
public function createDatabase(TenantWithDatabase $tenant): bool public function createDatabase(TenantWithDatabase $tenant): bool
{ {
$database = $tenant->database()->getName(); $database = $tenant->database()->getName();
$charset = $this->connection()->getConfig('charset');
$collation = $this->connection()->getConfig('collation'); // todo check why these are not used
return $this->connection()->statement("CREATE DATABASE [{$database}]"); return $this->connection()->statement("CREATE DATABASE [{$database}]");
} }

View file

@ -32,6 +32,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
// Grant permissions to any existing tables. This is used with RLS // Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager // todo@samuel refactor this along with the todo in TenantDatabaseManager
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
// while the RLS user should STILL get access to those tables
foreach ($tables as $table) { foreach ($tables as $table) {
$tableName = $table->table_name; $tableName = $table->table_name;

View file

@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Enums; namespace Stancl\Tenancy\Enums;
enum RouteMode /**
* Note: The backing values are not part of the public API and are subject to change.
*/
enum RouteMode: int
{ {
case TENANT; case CENTRAL = 0b01;
case CENTRAL; case TENANT = 0b10;
case UNIVERSAL; case UNIVERSAL = 0b11;
} }

View file

@ -7,15 +7,11 @@ namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here abstract class TenantEvent
{ {
use SerializesModels; use SerializesModels;
/** @var Tenant */ public function __construct(
public $tenant; public Tenant $tenant,
) {}
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
} }

View file

@ -10,9 +10,6 @@ class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedExce
{ {
public function __construct(int|string $tenant_id) public function __construct(int|string $tenant_id)
{ {
$this $this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)");
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)")
->title('Tenant could not be identified on this route because the used column is not whitelisted.')
->description('Please add the column to the list of allowed columns in the PathTenantResolver config.');
} }
} }

View file

@ -2,8 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
// todo perhaps create Identification namespace
namespace Stancl\Tenancy\Exceptions; namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
@ -12,9 +10,6 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified
{ {
public function __construct(int|string $tenant_id) public function __construct(int|string $tenant_id)
{ {
$this $this->tenantCouldNotBeIdentified("by tenant key: $tenant_id");
->tenantCouldNotBeIdentified("by tenant key: $tenant_id")
->title('Tenant could not be identified with that key')
->description('Are you sure the key is correct and the tenant exists?');
} }
} }

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi
{ {
public function __construct(int|string $tenant_id) public function __construct(int|string $tenant_id)
{ {
$this $this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id");
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id")
->title('Tenant could not be identified on this path')
->description('Did you forget to create a tenant for this path?');
} }
} }

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByRequestDataException extends TenantCouldNotBeI
{ {
public function __construct(mixed $payload) public function __construct(mixed $payload)
{ {
$this $this->tenantCouldNotBeIdentified("by request data with payload: $payload");
->tenantCouldNotBeIdentified("by request data with payload: $payload")
->title('Tenant could not be identified using this request data')
->description('Did you forget to create a tenant with this id?');
} }
} }

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedOnDomainException extends TenantCouldNotBeIdenti
{ {
public function __construct(string $domain) public function __construct(string $domain)
{ {
$this $this->tenantCouldNotBeIdentified("on domain $domain");
->tenantCouldNotBeIdentified("on domain $domain")
->title('Tenant could not be identified on this domain')
->description('Did you forget to create a tenant for this domain?');
} }
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features; namespace Stancl\Tenancy\Features;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Feature;
@ -18,8 +19,8 @@ class UserImpersonation implements Feature
public function bootstrap(Tenancy $tenancy): void public function bootstrap(Tenancy $tenancy): void
{ {
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): ImpersonationToken { $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
return ImpersonationToken::create([ return UserImpersonation::modelClass()::create([
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId, 'user_id' => $userId,
'redirect_url' => $redirectUrl, 'redirect_url' => $redirectUrl,
@ -30,10 +31,15 @@ class UserImpersonation implements Feature
} }
/** Impersonate a user and get an HTTP redirect response. */ /** Impersonate a user and get an HTTP redirect response. */
public static function makeResponse(#[\SensitiveParameter] string|ImpersonationToken $token, ?int $ttl = null): RedirectResponse public static function makeResponse(#[\SensitiveParameter] string|Model $token, ?int $ttl = null): RedirectResponse
{ {
/** @var ImpersonationToken $token */ /**
$token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token); * The model does NOT have to extend ImpersonationToken, but usually it WILL be a child
* of ImpersonationToken and this makes it clear to phpstan that the model has a redirect_url property.
*
* @var ImpersonationToken $token
*/
$token = $token instanceof Model ? $token : static::modelClass()::findOrFail($token);
$ttl ??= static::$ttl; $ttl ??= static::$ttl;
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
@ -54,6 +60,12 @@ class UserImpersonation implements Feature
return redirect($token->redirect_url); return redirect($token->redirect_url);
} }
/** @return class-string<Model> */
public static function modelClass(): string
{
return config('tenancy.models.impersonation_token');
}
public static function isImpersonating(): bool public static function isImpersonating(): bool
{ {
return session()->has('tenancy_impersonating'); return session()->has('tenancy_impersonating');
@ -62,7 +74,7 @@ class UserImpersonation implements Feature
/** /**
* Logout from the current domain and forget impersonation session. * Logout from the current domain and forget impersonation session.
*/ */
public static function leave(): void // todo@name possibly rename public static function stopImpersonating(): void
{ {
auth()->logout(); auth()->logout();

View file

@ -8,6 +8,8 @@ use Illuminate\Routing\Events\RouteMatched;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo@earlyIdReview
/** /**
* Remove the tenant parameter from the matched route when path identification is used globally. * Remove the tenant parameter from the matched route when path identification is used globally.
* *

View file

@ -10,6 +10,13 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma
{ {
public function getDomain(Request $request): string public function getDomain(Request $request): string
{ {
return $request->header('Origin', ''); if ($origin = $request->header('Origin', '')) {
$host = parse_url($origin, PHP_URL_HOST) ?? $origin;
assert(is_string($host) && strlen($host) > 0);
return $host;
}
return '';
} }
} }

View file

@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
{ {
use UsableWithEarlyIdentification; use UsableWithEarlyIdentification;
public static string $header = 'X-Tenant';
public static string $cookie = 'tenant';
public static string $queryParameter = 'tenant';
public static ?Closure $onFail = null; public static ?Closure $onFail = null;
public static bool $requireCookieEncryption = false; public static bool $requireCookieEncryption = false;
@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
protected function getPayload(Request $request): string|null protected function getPayload(Request $request): string|null
{ {
if (static::$header && $request->hasHeader(static::$header)) { $headerName = RequestDataTenantResolver::headerName();
$payload = $request->header(static::$header); $queryParameterName = RequestDataTenantResolver::queryParameterName();
} elseif ( $cookieName = RequestDataTenantResolver::cookieName();
static::$queryParameter &&
$request->has(static::$queryParameter) if ($headerName && $request->hasHeader($headerName)) {
) { $payload = $request->header($headerName);
$payload = $request->get(static::$queryParameter); } elseif ($queryParameterName && $request->has($queryParameterName)) {
} elseif (static::$cookie && $request->hasCookie(static::$cookie)) { $payload = $request->get($queryParameterName);
$payload = $request->cookie(static::$cookie); } elseif ($cookieName && $request->hasCookie($cookieName)) {
$payload = $request->cookie($cookieName);
if ($payload && is_string($payload)) { if ($payload && is_string($payload)) {
$payload = $this->getTenantFromCookie($payload); $payload = $this->getTenantFromCookie($cookieName, $payload);
} }
} else { } else {
$payload = null; $payload = null;
@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
return (bool) $this->getPayload($request); return (bool) $this->getPayload($request);
} }
protected function getTenantFromCookie(string $cookie): string|null protected function getTenantFromCookie(string $cookieName, string $cookieValue): string|null
{ {
// If the cookie looks like it's encrypted, we try decrypting it // If the cookie looks like it's encrypted, we try decrypting it
if (str_starts_with($cookie, 'eyJpdiI')) { if (str_starts_with($cookieValue, 'eyJpdiI')) {
try { try {
$json = base64_decode($cookie); $json = base64_decode($cookieValue);
$data = json_decode($json, true); $data = json_decode($json, true);
if ( if (
@ -100,9 +98,9 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
) { ) {
// We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just // We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just
// return null and the cookie payload would get skipped. // return null and the cookie payload would get skipped.
$cookie = CookieValuePrefix::validate( $cookieValue = CookieValuePrefix::validate(
static::$cookie, $cookieName,
Crypt::decryptString($cookie), Crypt::decryptString($cookieValue),
Crypt::getAllKeys() Crypt::getAllKeys()
); );
} }
@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
return null; return null;
} }
return $cookie; return $cookieValue;
} }
} }

View file

@ -68,7 +68,7 @@ class PreventAccessFromUnwantedDomains
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
} }
// todo@samuel // todo@samuel technically not an identification middleware but probably ok to keep this here
public function requestHasTenant(Request $request): bool public function requestHasTenant(Request $request): bool
{ {
return false; return false;

View file

@ -19,6 +19,10 @@ class ScopeSessions
public function handle(Request $request, Closure $next): mixed public function handle(Request $request, Closure $next): mixed
{ {
if (! tenancy()->initialized) { if (! tenancy()->initialized) {
if (tenancy()->routeIsUniversal(tenancy()->getRoute($request))) {
return $next($request);
}
throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed');
} }

View file

@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides;
use Illuminate\Cache\CacheManager as BaseCacheManager; use Illuminate\Cache\CacheManager as BaseCacheManager;
// todo@move move to Cache namespace?
class CacheManager extends BaseCacheManager class CacheManager extends BaseCacheManager
{ {
/** /**

View file

@ -9,43 +9,100 @@ use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use InvalidArgumentException; use InvalidArgumentException;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
/** /**
* This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled. * This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled.
* *
* TenancyUrlGenerator does two extra things: * TenancyUrlGenerator does a few extra things:
* 1. Autofill the {tenant} parameter in the tenant context with the current tenant if $passTenantParameterToRoutes is enabled (enabled by default) * - Autofills the tenant parameter in the tenant context with the current tenant.
* 2. Prepend the route name with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled (disabled by default) * This is done either by:
* - URL::defaults() -- if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is enabled.
* This generally has the best support since tools like e.g. Ziggy read defaults().
* - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled
* This is a more universal solution since it supports both path identification and query parameter identification.
* *
* Both of these can be skipped by passing the $bypassParameter (`['central' => true]` by default) * - Prepends route names passed to route() and URL::temporarySignedRoute()
* with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled.
* This is primarily useful when using route cloning with path identification.
*
* To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default).
*/ */
class TenancyUrlGenerator extends UrlGenerator class TenancyUrlGenerator extends UrlGenerator
{ {
/** /**
* Parameter which bypasses the behavior modification of route() and temporarySignedRoute(). * Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute().
* *
* E.g. route('tenant') => app.test/{tenant}/tenant (or app.test/tenant?tenant=tenantKey if the route doesn't accept the tenant parameter) * For example, in tenant context:
* route('tenant', [$bypassParameter => true]) => app.test/tenant. * Route::get('/', ...)->name('home');
* // query string identification
* Route::get('/tenant', ...)->middleware(InitializeTenancyByRequestData::class)->name('tenant.home');
* - route('home') => app.test/tenant?tenant=tenantKey
* - route('home', [$bypassParameter => true]) => app.test/
* - route('tenant.home', [$bypassParameter => true]) => app.test/tenant -- no query string added
*
* Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though
* it doesn't matter since it doesn't pass any extra parameters when not needed.
*
* @see UrlGeneratorBootstrapper
*/ */
public static string $bypassParameter = 'central'; public static string $bypassParameter = 'central';
/** /**
* Determine if the route names passed to `route()` or `temporarySignedRoute()` * Should route names passed to route() or temporarySignedRoute()
* should get prefixed with the tenant route name prefix. * get prefixed with the tenant route name prefix.
* *
* This is useful when using path identification with packages that generate URLs, * This is useful when using e.g. path identification with third-party packages
* like Jetstream, so that you don't have to manually prefix route names passed to each route() call. * where you don't have control over all route() calls or don't want to change
* too many files. Often this will be when using route cloning.
*/ */
public static bool $prefixRouteNames = false; public static bool $prefixRouteNames = false;
/** /**
* Determine if the tenant parameter should get passed * Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
* to the links generated by `route()` or `temporarySignedRoute()` whenever available
* (enabled by default works with both path and query string identification).
* *
* With path identification, you can disable this and use URL::defaults() instead (as an alternative solution). * This is useful with path or query parameter identification. The former can be handled
* more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults.
*
* @see UrlGeneratorBootstrapper
*/ */
public static bool $passTenantParameterToRoutes = true; public static bool $passTenantParameterToRoutes = false;
/**
* Route name overrides.
*
* Note: This behavior can be bypassed using $bypassParameter just like
* $prefixRouteNames and $passTenantParameterToRoutes.
*
* Example from a Jetstream integration:
* [
* 'profile.show' => 'tenant.profile.show',
* 'two-factor.login' => 'tenant.two-factor.login',
* ]
*
* In the tenant context:
* - `route('profile.show')` will return a URL as if you called `route('tenant.profile.show')`.
* - `route('profile.show', ['central' => true])` will return a URL as if you called `route('profile.show')`.
*/
public static array $overrides = [];
/**
* Follow the query_parameter config instead of the tenant_parameter_name (path identification) config.
*
* This only has an effect when:
* - $passTenantParameterToRoutes is enabled, and
* - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver.
*
* In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'),
* the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'.
*
* This is enabled by default because typically you will not need $passTenantParameterToRoutes with path identification.
* UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification.
*
* On the other hand, when using request data identification (specifically query string) you WILL need to pass the parameter
* directly to route() calls, therefore you would use $passTenantParameterToRoutes to avoid having to do that manually.
*/
public static bool $passQueryParameter = true;
/** /**
* Override the route() method so that the route name gets prefixed * Override the route() method so that the route name gets prefixed
@ -99,7 +156,7 @@ class TenancyUrlGenerator extends UrlGenerator
protected function prepareRouteInputs(string $name, array $parameters): array protected function prepareRouteInputs(string $name, array $parameters): array
{ {
if (! $this->routeBehaviorModificationBypassed($parameters)) { if (! $this->routeBehaviorModificationBypassed($parameters)) {
$name = $this->prefixRouteName($name); $name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
$parameters = $this->addTenantParameter($parameters); $parameters = $this->addTenantParameter($parameters);
} }
@ -124,10 +181,26 @@ class TenancyUrlGenerator extends UrlGenerator
} }
/** /**
* If `tenant()` isn't null, add tenant paramter to the passed parameters. * If `tenant()` isn't null, add the tenant parameter to the passed parameters.
*/ */
protected function addTenantParameter(array $parameters): array protected function addTenantParameter(array $parameters): array
{ {
return tenant() && static::$passTenantParameterToRoutes ? array_merge($parameters, [PathTenantResolver::tenantParameterName() => tenant()->getTenantKey()]) : $parameters; if (tenant() && static::$passTenantParameterToRoutes) {
if (static::$passQueryParameter) {
$queryParameterName = RequestDataTenantResolver::queryParameterName();
if ($queryParameterName !== null) {
return array_merge($parameters, [$queryParameterName => RequestDataTenantResolver::payloadValue(tenant())]);
}
}
return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]);
} else {
return $parameters;
}
}
protected function routeNameOverride(string $name): string|null
{
return static::$overrides[$name] ?? null;
} }
} }

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\Exceptions;
use Exception;
class RLSCommentConstraintException extends Exception
{
public function __construct(string|null $message = null)
{
parent::__construct($message ?? 'Invalid comment constraint.');
}
}

View file

@ -5,22 +5,90 @@ declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers; namespace Stancl\Tenancy\RLS\PolicyManagers;
use Illuminate\Database\DatabaseManager; use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException; use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations /**
* Generates queries for creating RLS policies
* for tables related to the tenants table.
*
* Usage:
* // Generate queries for creating RLS policies.
* // The queries will be returned in this format:
* // [
* // <<<SQL
* // CREATE POLICY authors_rls_policy ON authors USING (
* // tenant_id::text = current_setting('my.current_tenant')
* // );
* // SQL,
* // <<<SQL
* // CREATE POLICY posts_rls_policy ON posts USING (
* // author_id IN (
* // SELECT id
* // FROM authors
* // WHERE tenant_id::text = current_setting('my.current_tenant')
* // )
* // );
* // SQL,
* // ]
* // This is used In the CreateUserWithRLSPolicies command.
* // Calls shortestPaths() internally to generate paths, then generates queries for each path.
* $queries = app(TableRLSManager::class)->generateQueries();
*
* // Generate the shortest path from table X to the tenants table.
* // Calls shortestPathToTenantsTable() recursively.
* // The paths will be returned in this format:
* // [
* // 'foo_table' => [...$stepsLeadingToTenantsTable],
* // 'bar_table' => [
* // [
* // 'localColumn' => 'post_id',
* // 'foreignTable' => 'posts',
* // 'foreignColumn' => 'id'
* // ],
* // [
* // 'localColumn' => 'tenant_id',
* // 'foreignTable' => 'tenants',
* // 'foreignColumn' => 'id'
* // ],
* // ],
* // This is used in the CreateUserWithRLSPolicies command.
* $shortestPath = app(TableRLSManager::class)->shortestPaths();
*
* generateQueries() and shortestPaths() methods are the only public methods of this class.
* The rest of the methods are protected, and only used internally.
* To see how they're structured and how they work, you can check their annotations.
*/
class TableRLSManager implements RLSPolicyManager class TableRLSManager implements RLSPolicyManager
{ {
/**
* When true, all valid constraints are considered while generating paths for RLS policies,
* unless explicitly marked with a 'no-rls' comment.
*
* When false, only columns explicitly marked with 'rls' or 'rls table.column' comments are considered.
*/
public static bool $scopeByDefault = true; public static bool $scopeByDefault = true;
public function __construct( public function __construct(
protected DatabaseManager $database protected DatabaseManager $database
) {} ) {}
public function generateQueries(array $trees = []): array /**
* Generate queries that will be executed by the tenants:rls command
* for creating RLS policies for all tables related to the tenants table
* or for a passed array of paths.
*
* The passed paths should be formatted like this:
* [
* 'table_name' => [...$stepsLeadingToTenantsTable]
* ]
*/
public function generateQueries(array $paths = []): array
{ {
$queries = []; $queries = [];
foreach ($trees ?: $this->shortestPaths() as $table => $path) { foreach ($paths ?: $this->shortestPaths() as $table => $path) {
$queries[$table] = $this->generateQuery($table, $path); $queries[$table] = $this->generateQuery($table, $path);
} }
@ -28,184 +96,415 @@ class TableRLSManager implements RLSPolicyManager
} }
/** /**
* Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]). * Generate shortest paths from each table to the tenants table,
* structured like ['table_foo' => $shortestPathFromFoo, 'table_bar' => $shortestPathFromBar].
* *
* For example: * For example:
* *
* 'posts' => [ * 'posts' => [
* [ * [
* 'foreignKey' => 'tenant_id', * 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants', * 'foreignTable' => 'tenants',
* 'foreignId' => 'id' * 'foreignColumn' => 'id'
* ], * ],
* ], * ],
* 'comments' => [ * 'comments' => [
* [ * [
* 'foreignKey' => 'post_id', * 'localColumn' => 'post_id',
* 'foreignTable' => 'posts', * 'foreignTable' => 'posts',
* 'foreignId' => 'id' * 'foreignColumn' => 'id'
* ], * ],
* [ * [
* 'foreignKey' => 'tenant_id', * 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants', * 'foreignTable' => 'tenants',
* 'foreignId' => 'id' * 'foreignColumn' => 'id'
* ], * ],
* ], * ],
*
* @throws RecursiveRelationshipException When tables have recursive relationships and no other valid paths
* @throws RLSCommentConstraintException When comment constraints are malformed
*/ */
public function shortestPaths(array $trees = []): array public function shortestPaths(): array
{ {
$reducedTrees = []; $shortestPaths = [];
foreach ($trees ?: $this->generateTrees() as $table => $tree) { foreach ($this->getTableNames() as $tableName) {
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree); // Generate the shortest path from table named $tableName to the tenants table
$shortestPath = $this->shortestPathToTenantsTable($tableName);
if ($this->isValidPath($shortestPath)) {
// Format path steps to a more readable format (keep only the needed data)
$shortestPaths[$tableName] = array_map(fn (array $step) => [
'localColumn' => $step['localColumn'],
'foreignTable' => $step['foreignTable'],
'foreignColumn' => $step['foreignColumn'],
], $shortestPath['steps']);
} }
return $reducedTrees; // No valid path found. The shortest path either
// doesn't lead to the tenants table (ignore),
// or leads through a recursive relationship (throw an exception).
if ($shortestPath['recursive_relationship']) {
throw new RecursiveRelationshipException(
"Table '{$tableName}' has recursive relationships with no other valid paths to the tenants table."
);
}
}
return $shortestPaths;
} }
/** /**
* Generate trees of paths that lead to the tenants table * Create a path array with the given parameters.
* for the foreign keys of all tables only the paths that lead to the tenants table are included. * This method serves as a 'single source of truth' for the path array structure.
* *
* Also unset the 'comment' key from the retrieved path steps. * The 'steps' key contains the path steps returned by shortestPaths().
* The 'dead_end' and 'recursive_relationship' keys are just internal metadata.
*
* @param bool $deadEnd Whether the path is a dead end (no valid constraints leading to tenants table)
* @param bool $recursive Whether the path has recursive relationships
* @param array $steps Steps to the tenants table, each step being a formatted constraint
*/ */
public function generateTrees(): array protected function buildPath(bool $deadEnd = false, bool $recursive = false, array $steps = []): array
{ {
$trees = []; return [
$builder = $this->database->getSchemaBuilder(); 'dead_end' => $deadEnd,
'recursive_relationship' => $recursive,
// We loop through each table in the database 'steps' => $steps,
foreach ($builder->getTableListing() as $table) { ];
// For each table, we get a list of all foreign key columns
$foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) {
return $this->formatForeignKey($foreign, $table);
});
// We loop through each foreign key column and find
// all possible paths that lead to the tenants table
foreach ($foreignKeys as $foreign) {
$paths = [];
$this->generatePaths($table, $foreign, $paths);
foreach ($paths as &$path) {
foreach ($path as &$step) {
unset($step['comment']);
}
}
if (count($paths)) {
$trees[$table][$foreign['foreignKey']] = $paths;
}
}
}
return $trees;
}
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
{
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
throw new RecursiveRelationshipException;
}
$currentPath[] = $foreign;
if ($foreign['foreignTable'] === tenancy()->model()->getTable()) {
$comments = array_column($currentPath, 'comment');
$pathCanUseRls = static::$scopeByDefault ?
! in_array('no-rls', $comments) :
! in_array('no-rls', $comments) && ! in_array(null, $comments);
if ($pathCanUseRls) {
// If the foreign table is the tenants table, add the current path to $paths
$paths[] = $currentPath;
}
} else {
// If not, recursively generate paths for the foreign table
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
}
}
}
/** Get tree's non-nullable paths. */
protected function filterNonNullablePaths(array $tree): array
{
$nonNullablePaths = [];
foreach ($tree as $foreignKey => $paths) {
foreach ($paths as $path) {
$pathIsNullable = false;
foreach ($path as $step) {
if ($step['nullable']) {
$pathIsNullable = true;
break;
}
}
if (! $pathIsNullable) {
$nonNullablePaths[$foreignKey][] = $path;
}
}
}
return $nonNullablePaths;
}
/** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */
protected function findShortestPath(array $tree): array
{
$shortestPath = [];
foreach ($tree as $pathsForForeignKey) {
foreach ($pathsForForeignKey as $path) {
if (empty($shortestPath) || count($shortestPath) > count($path)) {
$shortestPath = $path;
foreach ($shortestPath as &$step) {
unset($step['nullable']);
}
}
}
}
return $shortestPath;
} }
/** /**
* Formats the foreign key array retrieved by Postgres to a more readable format. * Formats the retrieved constraint to a more readable format.
* *
* Also provides information about whether the foreign key is nullable, * Also provides internal metadata about
* and the foreign key column comment. These additional details are removed * - the constraint's nullability (the 'nullable' key),
* from the foreign keys/path steps before returning the final shortest paths. * - the constraint's comment
* *
* The 'comment' key gets deleted while generating the full trees (in generateTrees()), * These internal details are then omitted
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()). * from the constraints (or the "path steps")
* before returning the shortest paths in shortestPath().
* *
* [ * [
* 'foreignKey' => 'tenant_id', * 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants', * 'foreignTable' => 'tenants',
* 'foreignId' => 'id', * 'foreignColumn' => 'id',
* 'comment' => 'no-rls', // Foreign key comment used to explicitly enable/disable RLS * 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata)
* 'nullable' => false, // Whether the foreign key is nullable * 'nullable' => false, // Used to determine if the constraint is nullable (internal metadata)
* ]. * ].
*/ */
protected function formatForeignKey(array $foreignKey, string $table): array protected function formatForeignKey(array $constraint, string $table): array
{ {
// $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table) assert(count($constraint['columns']) === 1);
$localColumn = $constraint['columns'][0];
$comment = collect($this->database->getSchemaBuilder()->getColumns($table))
->filter(fn ($column) => $column['name'] === $localColumn)
->first()['comment'] ?? null;
$columnIsNullable = $this->database->selectOne(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
[$table, $localColumn]
)->is_nullable === 'YES';
assert(count($constraint['foreign_columns']) === 1);
return $this->formatConstraint(
localColumn: $localColumn,
foreignTable: $constraint['foreign_table'],
foreignColumn: $constraint['foreign_columns'][0],
comment: $comment,
nullable: $columnIsNullable
);
}
/** Single source of truth for our constraint format. */
protected function formatConstraint(
string $localColumn,
string $foreignTable,
string $foreignColumn,
string|null $comment,
bool $nullable
): array {
return [ return [
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0], 'localColumn' => $localColumn,
'foreignTable' => $foreignKey['foreign_table'], 'foreignTable' => $foreignTable,
'foreignId' => $foreignKey['foreign_columns'][0], 'foreignColumn' => $foreignColumn,
// Deleted in generateTrees() // Internal metadata omitted in shortestPaths()
'comment' => $this->getComment($table, $foreignKeyName), 'comment' => $comment,
// Deleted in shortestPaths() 'nullable' => $nullable,
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
]; ];
} }
/**
* Recursively traverse a table's constraints to find
* the shortest path to the tenants table.
*
* The shortest paths are cached in $cachedPaths to avoid
* generating them for already visited tables repeatedly.
*
* @param string $table The table to find a path from
* @param array &$cachedPaths Reference to array where discovered shortest paths are cached (including dead ends)
* @param array $visitedTables Already visited tables (used for detecting recursive relationships)
* @return array Paths with 'steps' (arrays of formatted constraints), 'dead_end' flag (bool), and 'recursive_relationship' flag (bool).
*/
protected function shortestPathToTenantsTable(
string $table,
array &$cachedPaths = [],
array $visitedTables = []
): array {
// Return the shortest path for this table if it was already found and cached
if (isset($cachedPaths[$table])) {
return $cachedPaths[$table];
}
// Reached tenants table (last step)
if ($table === tenancy()->model()->getTable()) {
// This pretty much just means we set $cachedPaths['tenants'] to an
// empty path. The significance of an empty path is that this class
// considers it to mean "you are at the tenants table".
$cachedPaths[$table] = $this->buildPath();
return $cachedPaths[$table];
}
$constraints = $this->getConstraints($table);
if (empty($constraints)) {
// Dead end
$cachedPaths[$table] = $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Find the optimal path from a table to the tenants table.
*
* Gather table's constraints (both foreign key constraints and comment constraints)
* and recursively find shortest paths through each constraint (non-nullable paths are preferred for reliability).
*
* Handle recursive relationships by skipping paths that would create loops.
* If there's no valid path in the end, and the table has recursive relationships,
* an appropriate exception is thrown.
*
* At the end, it returns the shortest non-nullable path if available,
* fall back to the overall shortest path.
*/
$visitedTables = [...$visitedTables, $table];
$shortestPath = [];
$hasRecursiveRelationships = false;
$hasValidPaths = false;
foreach ($constraints as $constraint) {
$foreignTable = $constraint['foreignTable'];
// Skip constraints that would create loops
if (in_array($foreignTable, $visitedTables)) {
$hasRecursiveRelationships = true;
continue;
}
// Recursive call
$pathThroughConstraint = $this->shortestPathToTenantsTable(
$foreignTable,
$cachedPaths,
$visitedTables
);
if ($pathThroughConstraint['recursive_relationship']) {
$hasRecursiveRelationships = true;
continue;
}
// Skip dead ends
if ($pathThroughConstraint['dead_end']) {
continue;
}
$hasValidPaths = true;
$path = $this->buildPath(steps: array_merge([$constraint], $pathThroughConstraint['steps']));
if ($this->isPathPreferable($path, $shortestPath)) {
$shortestPath = $path;
}
}
// Handle tables with only recursive relationships
if ($hasRecursiveRelationships && ! $hasValidPaths) {
// Don't cache paths that cause recursion - return right away.
// This allows tables with recursive relationships to be processed again.
// Example:
// - posts table has highlighted_comment_id that leads to the comments table
// - comments table has recursive_post_id that leads to the posts table (recursive relationship),
// - comments table also has tenant_id which leads to the tenants table (a valid path).
// If the recursive path got cached first, the path leading directly through tenants would never be found.
return $this->buildPath(recursive: true);
}
$cachedPaths[$table] = $shortestPath ?: $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Get all valid relationship constraints for a table. The constraints are also formatted.
* Combines both standard foreign key constraints and comment constraints.
*
* The schema builder retrieves foreign keys in the following format:
* [
* 'name' => 'posts_tenant_id_foreign',
* 'columns' => ['tenant_id'],
* 'foreign_table' => 'tenants',
* 'foreign_columns' => ['id'],
* ...
* ]
*
* We format that into a more readable format using formatForeignKey(),
* and that method uses formatConstraint(), which serves as a single source of truth
* for our constraint formatting. A formatted constraint looks like this:
* [
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignColumn' => 'id',
* 'comment' => 'no-rls',
* 'nullable' => false
* ]
*
* The comment constraints are retrieved using getFormattedCommentConstraints().
* These constraints are formatted in the method itself.
*/
protected function getConstraints(string $table): array
{
$formattedConstraints = array_merge(
array_map(
fn ($schemaStructure) => $this->formatForeignKey($schemaStructure, $table),
$this->database->getSchemaBuilder()->getForeignKeys($table)
),
$this->getFormattedCommentConstraints($table)
);
$validConstraints = [];
foreach ($formattedConstraints as $constraint) {
if (! $this->shouldSkipPathLeadingThroughConstraint($constraint)) {
$validConstraints[] = $constraint;
}
}
return $validConstraints;
}
/**
* Determine if a path leading through the passed constraint
* should be excluded from choosing the shortest path
* based on the constraint's comment.
*
* If $scopeByDefault is true, only skip paths leading through constraints flagged with the 'no-rls' comment.
* If $scopeByDefault is false, skip paths leading through any constraint, unless the key has explicit 'rls' or 'rls table.column' comments.
*
* @param array $constraint Formatted constraint
*/
protected function shouldSkipPathLeadingThroughConstraint(array $constraint): bool
{
$comment = $constraint['comment'] ?? null;
// Always skip constraints with the 'no-rls' comment
if ($comment === 'no-rls') {
return true;
}
if (static::$scopeByDefault) {
return false;
}
// When $scopeByDefault is false, skip every constraint
// with a comment that doesn't start with 'rls'.
if (! is_string($comment)) {
return true;
}
// Explicit scoping
if ($comment === 'rls') {
return false;
}
// Comment constraint
if (Str::startsWith($comment, 'rls ')) {
return false;
}
return true;
}
/**
* Retrieve a table's comment constraints.
*
* Comment constraints are columns with comments
* structured like "rls <foreign_table>.<foreign_column>".
*
* Returns an array of formatted comment constraints (check formatConstraint() to see the format).
*/
protected function getFormattedCommentConstraints(string $tableName): array
{
$commentConstraints = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
// Validate and format the comment constraints
$commentConstraints = array_map(
fn ($commentConstraint) => $this->parseCommentConstraint($commentConstraint, $tableName),
$commentConstraints
);
return $commentConstraints;
}
/**
* Parse and validate a comment constraint.
*
* This method validates that the table and column referenced
* in the comment exist, formats and returns the constraint.
*
* @throws RLSCommentConstraintException When comment format is invalid or references don't exist
*/
protected function parseCommentConstraint(array $commentConstraint, string $tableName): array
{
$comment = $commentConstraint['comment'];
$columnName = $commentConstraint['name'];
$builder = $this->database->getSchemaBuilder();
$constraint = explode('.', Str::after($comment, 'rls '));
// Validate comment constraint format
if (count($constraint) !== 2 || empty($constraint[0]) || empty($constraint[1])) {
throw new RLSCommentConstraintException("Malformed comment constraint on {$tableName}.{$columnName}: '{$comment}'");
}
$foreignTable = $constraint[0];
$foreignColumn = $constraint[1];
// Validate table existence
if (! $builder->hasTable($foreignTable)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent table '{$foreignTable}'");
}
// Validate column existence
if (! $builder->hasColumn($foreignTable, $foreignColumn)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent column '{$foreignTable}.{$foreignColumn}'");
}
// Return the formatted constraint
return $this->formatConstraint(
localColumn: $commentConstraint['name'],
foreignTable: $foreignTable,
foreignColumn: $foreignColumn,
comment: $commentConstraint['comment'],
nullable: $commentConstraint['nullable']
);
}
/** Generates a query that creates a row-level security policy for the passed table. */ /** Generates a query that creates a row-level security policy for the passed table. */
protected function generateQuery(string $table, array $path): string protected function generateQuery(string $table, array $path): string
{ {
@ -214,9 +513,9 @@ class TableRLSManager implements RLSPolicyManager
$sessionTenantKey = config('tenancy.rls.session_variable_name'); $sessionTenantKey = config('tenancy.rls.session_variable_name');
foreach ($path as $index => $relation) { foreach ($path as $index => $relation) {
$column = $relation['foreignKey']; $column = $relation['localColumn'];
$table = $relation['foreignTable']; $table = $relation['foreignTable'];
$foreignKey = $relation['foreignId']; $foreignKey = $relation['foreignColumn'];
$indentation = str_repeat(' ', ($index + 1) * 4); $indentation = str_repeat(' ', ($index + 1) * 4);
@ -249,12 +548,65 @@ class TableRLSManager implements RLSPolicyManager
return $query; return $query;
} }
protected function getComment(string $tableName, string $columnName): string|null /** Returns unprefixed table names. */
protected function getTableNames(): array
{ {
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName)) $builder = $this->database->getSchemaBuilder();
->filter(fn ($column) => $column['name'] === $columnName) $tables = [];
->first();
return $column['comment'] ?? null; foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) {
// E.g. "public.table_name" -> "table_name"
$tables[] = str($table)->afterLast('.')->toString();
}
return $tables;
}
/**
* Check if discovered path is valid for RLS policy generation.
*
* A valid path:
* - leads to tenants table (isn't dead end)
* - has at least one step (the tenants table itself will have no steps)
*/
protected function isValidPath(array $path): bool
{
return ! $path['dead_end'] && ! empty($path['steps']);
}
/**
* Determine if the passed path is preferred to the current shortest path.
*
* Non-nullable paths are preferred to nullable paths.
* From paths of the same nullability, the shorter will be preferred.
*/
protected function isPathPreferable(array $path, array $shortestPath): bool
{
if (! $shortestPath) {
return true;
}
$pathIsNullable = $this->isPathNullable($path['steps']);
$shortestPathIsNullable = $this->isPathNullable($shortestPath['steps']);
// Prefer non-nullable
if ($pathIsNullable !== $shortestPathIsNullable) {
return ! $pathIsNullable;
}
// Prefer shorter
return count($path['steps']) < count($shortestPath['steps']);
}
/** Determine if any step in the path is nullable. */
protected function isPathNullable(array $path): bool
{
foreach ($path as $step) {
if ($step['nullable']) {
return true;
}
}
return false;
} }
} }

View file

@ -73,7 +73,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
public static function tenantRouteNamePrefix(): string public static function tenantRouteNamePrefix(): string
{ {
return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.'; return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? 'tenant.';
} }
public static function tenantModelColumn(): string public static function tenantModelColumn(): string
@ -81,6 +81,11 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName();
} }
public static function tenantParameterValue(Tenant $tenant): string
{
return $tenant->getAttribute(static::tenantModelColumn());
}
/** @return string[] */ /** @return string[] */
public static function allowedExtraModelColumns(): array public static function allowedExtraModelColumns(): array
{ {

View file

@ -20,7 +20,9 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
{ {
$payload = (string) $args[0]; $payload = (string) $args[0];
if ($payload && $tenant = tenancy()->find($payload, withRelations: true)) { $column = static::tenantModelColumn();
if ($payload && $tenant = tenancy()->find($payload, $column, withRelations: true)) {
return $tenant; return $tenant;
} }
@ -29,8 +31,43 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function getPossibleCacheKeys(Tenant&Model $tenant): array public function getPossibleCacheKeys(Tenant&Model $tenant): array
{ {
// todo@tests
return [ return [
$this->formatCacheKey($tenant->getTenantKey()), $this->formatCacheKey(static::payloadValue($tenant)),
]; ];
} }
public static function payloadValue(Tenant $tenant): string
{
return $tenant->getAttribute(static::tenantModelColumn());
}
public static function tenantModelColumn(): string
{
return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName();
}
/**
* Returns the name of the header used for identification, or null if header identification is disabled.
*/
public static function headerName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.header');
}
/**
* Returns the name of the query parameter used for identification, or null if query parameter identification is disabled.
*/
public static function queryParameterName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.query_parameter');
}
/**
* Returns the name of the cookie used for identification, or null if cookie identification is disabled.
*/
public static function cookieName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.cookie');
}
} }

View file

@ -9,8 +9,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
// todo@move move all resource syncing-related things to a separate namespace?
/** /**
* @property-read TenantWithDatabase[]|Collection<int, TenantWithDatabase&Model> $tenants * @property-read TenantWithDatabase[]|Collection<int, TenantWithDatabase&Model> $tenants
*/ */

View file

@ -77,10 +77,12 @@ class Tenancy
public function run(Tenant $tenant, Closure $callback): mixed public function run(Tenant $tenant, Closure $callback): mixed
{ {
$originalTenant = $this->tenant; $originalTenant = $this->tenant;
$result = null;
try {
$this->initialize($tenant); $this->initialize($tenant);
$result = $callback($tenant); $result = $callback($tenant);
} finally {
if ($result instanceof PendingDispatch) { // #1277 if ($result instanceof PendingDispatch) { // #1277
$result = null; $result = null;
} }
@ -90,6 +92,7 @@ class Tenancy
} else { } else {
$this->end(); $this->end();
} }
}
return $result; return $result;
} }
@ -204,8 +207,10 @@ class Tenancy
// Wrap string in array // Wrap string in array
$tenants = is_string($tenants) ? [$tenants] : $tenants; $tenants = is_string($tenants) ? [$tenants] : $tenants;
// Use all tenants if $tenants is falsy // If $tenants is falsy by this point (e.g. an empty array) there's no work to be done
$tenants = $tenants ?: $this->model()->cursor(); // todo@phpstan phpstan thinks this isn't needed, but tests fail without it if (! $tenants) {
return;
}
$originalTenant = $this->tenant; $originalTenant = $this->tenant;

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy; namespace Stancl\Tenancy;
use Closure;
use Illuminate\Cache\CacheManager; use Illuminate\Cache\CacheManager;
use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Routing\Events\RouteMatched; use Illuminate\Routing\Events\RouteMatched;
@ -18,9 +19,15 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class TenancyServiceProvider extends ServiceProvider class TenancyServiceProvider extends ServiceProvider
{ {
public static Closure|null $configure = null;
/* Register services. */ /* Register services. */
public function register(): void public function register(): void
{ {
if (static::$configure) {
(static::$configure)();
}
$this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy'); $this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy');
$this->app->singleton(Database\DatabaseManager::class); $this->app->singleton(Database\DatabaseManager::class);

View file

@ -18,7 +18,7 @@ class RandomHexGenerator implements UniqueIdentifierGenerator
{ {
public static int $bytes = 6; public static int $bytes = 6;
public static function generate(Model $model): string public static function generate(Model $model): string|int
{ {
return bin2hex(random_bytes(static::$bytes)); return bin2hex(random_bytes(static::$bytes));
} }

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/**
* Generates a cryptographically secure random integer for the tenant key.
*/
class RandomIntGenerator implements UniqueIdentifierGenerator
{
public static int $min = 0;
public static int $max = PHP_INT_MAX;
public static function generate(Model $model): string|int
{
return random_int(static::$min, static::$max);
}
}

View file

@ -18,7 +18,7 @@ class RandomStringGenerator implements UniqueIdentifierGenerator
{ {
public static int $length = 8; public static int $length = 8;
public static function generate(Model $model): string public static function generate(Model $model): string|int
{ {
return Str::random(static::$length); return Str::random(static::$length);
} }

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/**
* Generates a UUID for the tenant key.
*/
class ULIDGenerator implements UniqueIdentifierGenerator
{
public static function generate(Model $model): string|int
{
return Str::ulid()->toString();
}
}

View file

@ -13,7 +13,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
*/ */
class UUIDGenerator implements UniqueIdentifierGenerator class UUIDGenerator implements UniqueIdentifierGenerator
{ {
public static function generate(Model $model): string public static function generate(Model $model): string|int
{ {
return Uuid::uuid4()->toString(); return Uuid::uuid4()->toString();
} }

View file

@ -30,6 +30,7 @@ if (! function_exists('tenant')) {
return app(Tenant::class); return app(Tenant::class);
} }
// @phpstan-ignore-next-line nullsafe.neverNull
return app(Tenant::class)?->getAttribute($key); return app(Tenant::class)?->getAttribute($key);
} }
} }

8
t
View file

@ -1,3 +1,9 @@
#!/bin/bash #!/bin/bash
docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always"
else
COLOR_FLAG="--colors=never"
fi
docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} --no-coverage --filter "$@"

8
test
View file

@ -1,4 +1,10 @@
#!/bin/bash #!/bin/bash
if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always"
else
COLOR_FLAG="--colors=never"
fi
# --columns doesn't seem to work at the moment, so we're setting it using an environment variable # --columns doesn't seem to work at the moment, so we're setting it using an environment variable
docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} "$@"

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction; use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Illuminate\Support\Facades\File;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
@ -35,11 +36,15 @@ test('create storage symlinks action works', function() {
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
$this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey")); // The symlink doesn't exist
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse();
(new CreateStorageSymlinksAction)($tenant); (new CreateStorageSymlinksAction)($tenant);
$this->assertDirectoryExists($publicPath); // The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
$this->assertEquals(storage_path("app/public/"), readlink($publicPath)); $this->assertEquals(storage_path("app/public/"), readlink($publicPath));
}); });
@ -61,9 +66,48 @@ test('remove storage symlinks action works', function() {
(new CreateStorageSymlinksAction)($tenant); (new CreateStorageSymlinksAction)($tenant);
$this->assertDirectoryExists($publicPath = public_path("public-$tenantKey")); // The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
(new RemoveStorageSymlinksAction)($tenant); (new RemoveStorageSymlinksAction)($tenant);
$this->assertDirectoryDoesNotExist($publicPath); // The symlink doesn't exist
expect(is_link($publicPath))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse();
});
test('removing tenant symlinks works even if the symlinks are invalid', function() {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%'
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
(new CreateStorageSymlinksAction)($tenant);
// The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
// Make the symlink invalid by deleting the tenant storage directory
$storagePath = storage_path();
File::deleteDirectory($storagePath);
// The symlink still exists, but isn't valid
expect(is_link($publicPath))->toBeTrue();
expect(file_exists($publicPath))->toBeFalse();
(new RemoveStorageSymlinksAction)($tenant);
expect(is_link($publicPath))->toBeFalse();
}); });

View file

@ -9,6 +9,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
$this->mockConsoleOutput = false; $this->mockConsoleOutput = false;

View file

@ -15,6 +15,7 @@ use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -9,6 +9,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]); config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]);

View file

@ -13,6 +13,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners; use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
/** /**
* This collection of regression tests verifies that SessionTenancyBootstrapper * This collection of regression tests verifies that SessionTenancyBootstrapper

View file

@ -6,6 +6,9 @@ use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -13,6 +13,14 @@ use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
FortifyRouteBootstrapper::$passTenantParameter = true;
});
afterEach(function () {
FortifyRouteBootstrapper::$passTenantParameter = true;
FortifyRouteBootstrapper::$fortifyRedirectMap = [];
FortifyRouteBootstrapper::$fortifyHome = 'tenant.dashboard';
FortifyRouteBootstrapper::$passQueryParameter = false;
}); });
test('fortify route tenancy bootstrapper updates fortify config correctly', function() { test('fortify route tenancy bootstrapper updates fortify config correctly', function() {
@ -25,53 +33,31 @@ test('fortify route tenancy bootstrapper updates fortify config correctly', func
return true; return true;
})->name($homeRouteName = 'home'); })->name($homeRouteName = 'home');
Route::get('/{tenant}/home', function () {
return true;
})->name($pathIdHomeRouteName = 'tenant.home');
Route::get('/welcome', function () { Route::get('/welcome', function () {
return true; return true;
})->name($welcomeRouteName = 'welcome'); })->name($welcomeRouteName = 'welcome');
Route::get('/{tenant}/welcome', function () {
return true;
})->name($pathIdWelcomeRouteName = 'path.welcome');
FortifyRouteBootstrapper::$fortifyHome = $homeRouteName; FortifyRouteBootstrapper::$fortifyHome = $homeRouteName;
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = $welcomeRouteName;
// Make login redirect to the central welcome route expect(config('fortify.home'))->toBe($originalFortifyHome);
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [ expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
'route_name' => $welcomeRouteName,
'context' => Context::CENTRAL,
];
FortifyRouteBootstrapper::$passTenantParameter = true;
tenancy()->initialize($tenant = Tenant::create()); tenancy()->initialize($tenant = Tenant::create());
// The bootstraper makes fortify.home always receive the tenant parameter
expect(config('fortify.home'))->toBe('http://localhost/home?tenant=' . $tenant->getTenantKey()); expect(config('fortify.home'))->toBe('http://localhost/home?tenant=' . $tenant->getTenantKey());
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
// The login redirect route has the central context specified, so it doesn't receive the tenant parameter
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
tenancy()->end(); tenancy()->end();
expect(config('fortify.home'))->toBe($originalFortifyHome); expect(config('fortify.home'))->toBe($originalFortifyHome);
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects); expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
// Making a route's context will pass the tenant parameter to the route FortifyRouteBootstrapper::$passTenantParameter = false;
FortifyRouteBootstrapper::$fortifyRedirectMap['login']['context'] = Context::TENANT;
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
expect(config('fortify.home'))->toBe('http://localhost/home');
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]); expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
// Make the home and login route accept the tenant as a route parameter
// To confirm that tenant route parameter gets filled automatically too (path identification works as well as query string)
FortifyRouteBootstrapper::$fortifyHome = $pathIdHomeRouteName;
FortifyRouteBootstrapper::$fortifyRedirectMap['login']['route_name'] = $pathIdWelcomeRouteName;
tenancy()->end(); tenancy()->end();
expect(config('fortify.home'))->toBe($originalFortifyHome);
tenancy()->initialize($tenant); expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
expect(config('fortify.home'))->toBe("http://localhost/{$tenant->getTenantKey()}/home");
expect(config('fortify.redirects'))->toEqual(['login' => "http://localhost/{$tenant->getTenantKey()}/welcome"]);
}); });

View file

@ -10,18 +10,23 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper; use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
RootUrlBootstrapper::$rootUrlOverride = null; RootUrlBootstrapper::$rootUrlOverride = null;
RootUrlBootstrapper::$rootUrlOverrideInTests = true;
}); });
afterEach(function () { afterEach(function () {
RootUrlBootstrapper::$rootUrlOverride = null; RootUrlBootstrapper::$rootUrlOverride = null;
RootUrlBootstrapper::$rootUrlOverrideInTests = false;
}); });
test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one after tenancy ends', function() { test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one when ending tenancy', function() {
config(['tenancy.bootstrappers' => [RootUrlBootstrapper::class]]); config(['tenancy.bootstrappers' => [RootUrlBootstrapper::class]]);
Route::group([ Route::group([

View file

@ -1,5 +1,6 @@
<?php <?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\UrlGenerator; use Illuminate\Routing\UrlGenerator;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -12,19 +13,26 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
}); });
afterEach(function () { afterEach(function () {
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
}); });
test('url generator bootstrapper swaps the url generator instance correctly', function() { test('url generator bootstrapper swaps the url generator instance correctly', function() {
@ -41,42 +49,246 @@ test('url generator bootstrapper swaps the url generator instance correctly', fu
->not()->toBeInstanceOf(TenancyUrlGenerator::class); ->not()->toBeInstanceOf(TenancyUrlGenerator::class);
}); });
test('url generator bootstrapper can prefix route names passed to the route helper', function() { test('tenancy url generator can prefix route names passed to the route helper', function() {
Route::get('/central/home', fn () => route('home'))->name('home'); config([
// Tenant route name prefix is 'tenant.' by default 'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'custom_prefix.',
Route::get('/{tenant}/home', fn () => route('tenant.home'))->name('tenant.home')->middleware(['tenant', InitializeTenancyByPath::class]); ]);
Route::get('/central/home', fn () => '')->name('home');
Route::get('/tenant/home', fn () => '')->name('custom_prefix.home');
$tenant = Tenant::create(); $tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
$centralRouteUrl = route('home');
$tenantRouteUrl = route('tenant.home', ['tenant' => $tenantKey]);
TenancyUrlGenerator::$bypassParameter = 'bypassParameter';
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
// Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false // Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false (default)
expect(route('home'))->not()->toBe($centralRouteUrl); expect(route('home'))->toBe('http://localhost/central/home');
// When TenancyUrlGenerator::$passTenantParameterToRoutes is true (default)
// The route helper receives the tenant parameter
// So in order to generate central URL, we have to pass the bypass parameter
expect(route('home', ['bypassParameter' => true]))->toBe($centralRouteUrl);
// When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed automatically.
TenancyUrlGenerator::$prefixRouteNames = true; TenancyUrlGenerator::$prefixRouteNames = true;
// The $prefixRouteNames property is true
// The route name passed to the route() helper ('home') gets prefixed prefixed with 'tenant.' automatically
expect(route('home'))->toBe($tenantRouteUrl);
// The 'tenant.home' route name doesn't get prefixed because it is already prefixed with 'tenant.' expect(route('home'))->toBe('http://localhost/tenant/home');
// Also, the route receives the tenant parameter automatically
expect(route('tenant.home'))->toBe($tenantRouteUrl); // The 'custom_prefix.home' route name doesn't get prefixed -- it is already prefixed with 'custom_prefix.'
expect(route('custom_prefix.home'))->toBe('http://localhost/tenant/home');
// Ending tenancy reverts route() behavior changes // Ending tenancy reverts route() behavior changes
tenancy()->end(); tenancy()->end();
expect(route('home'))->toBe($centralRouteUrl); expect(route('home'))->toBe('http://localhost/central/home');
});
test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults;
TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes;
$tenant = Tenant::create();
Route::get('/{tenant}/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByPath::class]);
tenancy()->initialize($tenant);
if (! $addTenantParameterToDefaults && ! $passTenantParameterToRoutes) {
expect(fn () => route('tenant.home'))->toThrow(UrlGenerationException::class, 'Missing parameter: tenant');
} else {
// If at least *one* of the approaches was used, the parameter will make its way to the route
expect(route('tenant.home'))->toBe("http://localhost/{$tenant->id}/home");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
}
})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults
->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes
test('request data identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults;
TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes;
$tenant = Tenant::create();
Route::get('/tenant/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByRequestData::class]);
tenancy()->initialize($tenant);
if ($passTenantParameterToRoutes) {
// Only $passTenantParameterToRoutes has an effect, defaults do not affect request data URL generation
expect(route('tenant.home'))->toBe("http://localhost/tenant/home?tenant={$tenant->id}");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
} else {
expect(route('tenant.home'))->toBe("http://localhost/tenant/home");
expect(fn () => $this->withoutExceptionHandling()->get(route('tenant.home')))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
}
})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults
->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes
test('changing request data query parameter and model column is respected by the url generator', function () {
config([
'tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class],
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'team',
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => 'slug',
]);
Tenant::$extraCustomColumns = ['slug'];
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
$tenant = Tenant::create(['slug' => 'acme']);
Route::get('/tenant/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByRequestData::class]);
tenancy()->initialize($tenant);
expect(route('tenant.home'))->toBe("http://localhost/tenant/home?team=acme");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
});
test('setting extra model columns sets additional URL defaults', function () {
Tenant::$extraCustomColumns = ['slug'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
Route::get('/{tenant}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
Route::get('/{tenant:slug}/fooslug/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug');
$tenant = Tenant::create(['slug' => 'acme']);
// In central context, no URL defaults are applied
expect(route('foo', [$tenant->getTenantKey(), 'bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar");
pest()->get(route('foo', [$tenant->getTenantKey(), 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
expect(route('fooslug', ['acme', 'bar']))->toBe('http://localhost/acme/fooslug/bar');
pest()->get(route('fooslug', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
expect(route('fooslug', ['bar']))->toBe('http://localhost/acme/fooslug/bar');
pest()->get(route('fooslug', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('changing the tenant model column changes the default value for the tenant parameter', function () {
Tenant::$extraCustomColumns = ['slug'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
Route::get('/{tenant}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
$tenant = Tenant::create(['slug' => 'acme']);
// In central context, no URL defaults are applied
expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('changing the tenant parameter name is respected by the url generator', function () {
Tenant::$extraCustomColumns = ['slug', 'slug2'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug2']]);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
$table->string('slug2')->unique();
});
Route::get('/{team}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
Route::get('/{team:slug2}/fooslug2/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug2');
$tenant = Tenant::create(['slug' => 'acme', 'slug2' => 'acme2']);
// In central context, no URL defaults are applied
expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
expect(route('fooslug2', ['acme2', 'bar']))->toBe("http://localhost/acme2/fooslug2/bar");
pest()->get(route('fooslug2', ['acme2', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
expect(route('fooslug2', ['bar']))->toBe("http://localhost/acme2/fooslug2/bar");
pest()->get(route('fooslug2', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('url generator can override specific route names', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/foo', fn () => 'foo')->name('foo');
Route::get('/bar', fn () => 'bar')->name('bar');
Route::get('/baz', fn () => 'baz')->name('baz'); // Not overridden
TenancyUrlGenerator::$overrides = ['foo' => 'bar'];
expect(route('foo'))->toBe(url('/foo'));
expect(route('bar'))->toBe(url('/bar'));
expect(route('baz'))->toBe(url('/baz'));
tenancy()->initialize(Tenant::create());
expect(route('foo'))->toBe(url('/bar'));
expect(route('bar'))->toBe(url('/bar')); // not overridden
expect(route('baz'))->toBe(url('/baz')); // not overridden
// Bypass the override
expect(route('foo', ['central' => true]))->toBe(url('/foo'));
}); });
test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () { test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () {
@ -105,54 +317,8 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b
->not()->toContain('bypassParameter'); ->not()->toContain('bypassParameter');
// When the bypass parameter is false, the generated route URL points to the prefixed route ('tenant.home') // When the bypass parameter is false, the generated route URL points to the prefixed route ('tenant.home')
expect(route('home', ['bypassParameter' => false]))->toBe($tenantRouteUrl) // The tenant parameter is not passed automatically since both
// UrlGeneratorBootstrapper::$addTenantParameterToDefaults and TenancyUrlGenerator::$passTenantParameterToRoutes are false by default
expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl)
->not()->toContain('bypassParameter'); ->not()->toContain('bypassParameter');
}); });
test('url generator bootstrapper can make route helper generate links with the tenant parameter', function() {
Route::get('/query_string', fn () => route('query_string'))->name('query_string')->middleware(['universal', InitializeTenancyByRequestData::class]);
Route::get('/path', fn () => route('path'))->name('path');
Route::get('/{tenant}/path', fn () => route('tenant.path'))->name('tenant.path')->middleware([InitializeTenancyByPath::class]);
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
$queryStringCentralUrl = route('query_string');
$queryStringTenantUrl = route('query_string', ['tenant' => $tenantKey]);
$pathCentralUrl = route('path');
$pathTenantUrl = route('tenant.path', ['tenant' => $tenantKey]);
// Makes the route helper receive the tenant parameter whenever available
// Unless the bypass parameter is true
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenancyUrlGenerator::$bypassParameter = 'bypassParameter';
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
expect(route('path'))->toBe($pathCentralUrl);
// Tenant parameter required, but not passed since tenancy wasn't initialized
expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class);
tenancy()->initialize($tenant);
// Tenant parameter is passed automatically
expect(route('path'))->not()->toBe($pathCentralUrl); // Parameter added as query string bypassParameter needed
expect(route('path', ['bypassParameter' => true]))->toBe($pathCentralUrl);
expect(route('tenant.path'))->toBe($pathTenantUrl);
expect(route('query_string'))->toBe($queryStringTenantUrl)->toContain('tenant=');
expect(route('query_string', ['bypassParameter' => 'true']))->toBe($queryStringCentralUrl)->not()->toContain('tenant=');
tenancy()->end();
expect(route('query_string'))->toBe($queryStringCentralUrl);
// Tenant parameter required, but shouldn't be passed since tenancy isn't initialized
expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class);
// Route-level identification
pest()->get("http://localhost/query_string")->assertSee($queryStringCentralUrl);
pest()->get("http://localhost/query_string?tenant=$tenantKey")->assertSee($queryStringTenantUrl);
pest()->get("http://localhost/path")->assertSee($pathCentralUrl);
pest()->get("http://localhost/$tenantKey/path")->assertSee($pathTenantUrl);
});

View file

@ -19,6 +19,7 @@ use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () { beforeEach(function () {
withTenantDatabases(); withTenantDatabases();

View file

@ -11,9 +11,11 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\PathIdentificationManager;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest;
test('tenants can be resolved using cached resolvers', function (string $resolver) { test('tenants can be resolved using cached resolvers', function (string $resolver) {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']); $tenant = Tenant::create(['id' => $tenantKey = 'acme']);
@ -84,6 +86,34 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
RequestDataTenantResolver::class, RequestDataTenantResolver::class,
]); ]);
test('cache is invalidated when the tenant is deleted', function (string $resolver) {
DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty();
$tenant->delete();
DB::flushQueryLog();
expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenantKey)))->toThrow(TenantCouldNotBeIdentifiedException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
]);
test('cache is invalidated when a tenants domain is changed', function () { test('cache is invalidated when a tenants domain is changed', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']); $tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey); $tenant->createDomain($tenantKey);
@ -110,6 +140,26 @@ test('cache is invalidated when a tenants domain is changed', function () {
pest()->assertNotEmpty(DB::getQueryLog()); // not empty pest()->assertNotEmpty(DB::getQueryLog()); // not empty
}); });
test('cache is invalidated when a tenants domain is deleted', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty(); // empty
$tenant->domains->first()->delete();
DB::flushQueryLog();
expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
});
test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() { test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
DB::enableQueryLog(); DB::enableQueryLog();

View file

@ -1,269 +1,195 @@
<?php <?php
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Contracts\Http\Kernel;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Tests\Etc\HasMiddlewareController; use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
test('a route can be universal using path identification', function (array $routeMiddleware, array $globalMiddleware) { test('CloneRoutesAsTenant action clones routes with clone middleware by default', function () {
foreach ($globalMiddleware as $middleware) { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
if ($middleware === 'universal') { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]);
} else {
app(Kernel::class)->pushMiddleware($middleware);
}
}
RouteFacade::get('/foo', function () { // Should not be cloned
return tenancy()->initialized RouteFacade::get('/central', fn () => true)->name('central');
? 'Tenancy is initialized.'
: 'Tenancy is not initialized.';
})->middleware($routeMiddleware);
config(['tenancy._tests.static_identification_middleware' => $routeMiddleware]); // Should be cloned since no specific routes are passed to the action using cloneRoute() and the route has the 'clone' middleware
RouteFacade::get('/foo', fn () => true)->middleware(['clone'])->name('foo');
RouteFacade::get('/bar', [HasMiddlewareController::class, 'index']); $originalRoutes = RouteFacade::getRoutes()->get();
/** @var CloneRoutesAsTenant $cloneRoutesAction */ /** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle(); $cloneRoutesAction->handle();
$tenantKey = Tenant::create()->getTenantKey(); $newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
pest()->get("http://localhost/foo") expect($newRoutes->count())->toEqual(1);
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
pest()->get("http://localhost/{$tenantKey}/foo") $newRoute = $newRoutes->first();
->assertSuccessful() expect($newRoute->uri())->toBe('{team}/foo');
->assertSee('Tenancy is initialized.');
tenancy()->end();
pest()->get("http://localhost/bar")
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
pest()->get("http://localhost/{$tenantKey}/bar")
->assertSuccessful()
->assertSee('Tenancy is initialized.');
})->with('path identification types');
test('CloneRoutesAsTenant registers prefixed duplicates of universal routes correctly', function (bool $kernelIdentification, bool $useController, string $tenantMiddleware) {
$routeMiddleware = ['universal'];
config(['tenancy.identification.path_identification_middleware' => [$tenantMiddleware]]);
if ($kernelIdentification) {
app(Kernel::class)->pushMiddleware($tenantMiddleware);
} else {
$routeMiddleware[] = $tenantMiddleware;
}
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => $tenantParameterName = 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => $tenantRouteNamePrefix = 'team-route.']);
// Test that routes with controllers as well as routes with closure actions get cloned correctly
$universalRoute = RouteFacade::get('/home', $useController ? Controller::class : fn () => tenant() ? 'Tenancy is initialized.' : 'Tenancy is not initialized.')->middleware($routeMiddleware)->name('home');
$centralRoute = RouteFacade::get('/central', fn () => true)->name('central');
config(['tenancy._tests.static_identification_middleware' => $routeMiddleware]);
$universalRoute2 = RouteFacade::get('/bar', [HasMiddlewareController::class, 'index'])->name('second-home');
expect($routes = RouteFacade::getRoutes()->get())->toContain($universalRoute)
->toContain($universalRoute2)
->toContain($centralRoute);
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle();
expect($routesAfterRegisteringDuplicates = RouteFacade::getRoutes()->get())
->toContain($universalRoute)
->toContain($centralRoute);
$newRoutes = collect($routesAfterRegisteringDuplicates)->filter(fn ($route) => ! in_array($route, $routes));
expect($newRoutes->first()->uri())->toBe('{' . $tenantParameterName . '}' . '/' . $universalRoute->uri());
expect($newRoutes->last()->uri())->toBe('{' . $tenantParameterName . '}' . '/' . $universalRoute2->uri());
// Universal flag is excluded from the route middleware
expect(tenancy()->getRouteMiddleware($newRoutes->first()))
->toEqualCanonicalizing(
array_values(array_filter(array_merge(tenancy()->getRouteMiddleware($universalRoute), ['tenant']),
fn($middleware) => $middleware !== 'universal'))
);
// Universal flag is provided statically in the route's controller, so we cannot exclude it
expect(tenancy()->getRouteMiddleware($newRoutes->last()))
->toEqualCanonicalizing(
array_values(array_merge(tenancy()->getRouteMiddleware($universalRoute2), ['tenant']))
);
$tenant = Tenant::create(); $tenant = Tenant::create();
pest()->get(route($centralRouteName = $universalRoute->getName()))->assertSee('Tenancy is not initialized.'); expect($newRoute->getName())->toBe('team-route.foo');
pest()->get(route($centralRouteName2 = $universalRoute2->getName()))->assertSee('Tenancy is not initialized.'); pest()->get(route('team-route.foo', ['team' => $tenant->id]))->assertOk();
pest()->get(route($tenantRouteName = $newRoutes->first()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.'); expect(tenancy()->getRouteMiddleware($newRoute))
tenancy()->end(); ->toContain('tenant')
pest()->get(route($tenantRouteName2 = $newRoutes->last()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.'); ->not()->toContain('clone');
});
expect($tenantRouteName)->toBe($tenantRouteNamePrefix . $universalRoute->getName()); test('CloneRoutesAsTenant action clones only specified routes when using cloneRoute()', function () {
expect($tenantRouteName2)->toBe($tenantRouteNamePrefix . $universalRoute2->getName()); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
expect($centralRouteName)->toBe($universalRoute->getName()); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
expect($centralRouteName2)->toBe($universalRoute2->getName());
})->with([
'kernel identification' => true,
'route-level identification' => false,
// Creates a matrix (multiple with())
])->with([
'use controller' => true,
'use closure' => false
])->with([
'path identification middleware' => InitializeTenancyByPath::class,
'custom path identification middleware' => CustomInitializeTenancyByPath::class,
]);
test('CloneRoutesAsTenant only clones routes with path identification by default', function () { // Should not be cloned
app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class); RouteFacade::get('/central', fn () => true)->name('central');
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get()); // Should not be cloned despite having clone middleware because cloneRoute() is used
RouteFacade::get('/foo', fn () => true)->middleware(['clone'])->name('foo');
$initialRouteCount = $currentRouteCount(); // The only route that should be cloned
$routeToClone = RouteFacade::get('/home', fn () => true)->name('home');
// Path identification is used globally, and this route doesn't use a specific identification middleware, meaning path identification is used and the route should get cloned $originalRoutes = RouteFacade::getRoutes()->get();
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware('universal')->name('home');
// The route uses a specific identification middleware other than InitializeTenancyByPath the route shouldn't get cloned
RouteFacade::get('/home-domain-id', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware(['universal', InitializeTenancyByDomain::class])->name('home-domain-id');
expect($currentRouteCount())->toBe($newRouteCount = $initialRouteCount + 2);
/** @var CloneRoutesAsTenant $cloneRoutesAction */ /** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
// If a specific route is passed to the action, clone only that route (cloneRoute() can be chained as many times as needed)
$cloneRoutesAction->cloneRoute($routeToClone);
$cloneRoutesAction->handle(); $cloneRoutesAction->handle();
// Only one of the two routes gets cloned $newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
expect($currentRouteCount())->toBe($newRouteCount + 1);
expect($newRoutes->count())->toEqual(1);
$newRoute = $newRoutes->first();
expect($newRoute->uri())->toBe('{team}/home');
$tenant = Tenant::create();
expect($newRoute->getName())->toBe('team-route.home');
pest()->get(route('team-route.home', ['team' => $tenant->id]))->assertOk();
expect(tenancy()->getRouteMiddleware($newRoute))
->toContain('tenant')
->not()->toContain('clone');
// Verify that the route with clone middleware was NOT cloned
expect(RouteFacade::getRoutes()->getByName('team-route.foo'))->toBeNull();
}); });
test('custom callbacks can be used for cloning universal routes', function () { test('all routes with any of the middleware specified in cloneRoutesWithMiddleware will be cloned by default', function (array $cloneRoutesWithMiddleware) {
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware(['universal', InitializeTenancyByPath::class])->name($routeName = 'home'); RouteFacade::get('/foo', fn () => true)->name('foo');
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
RouteFacade::get('/baz', fn () => true)->name('baz')->middleware(['duplicate']);
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount();
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction
->cloneRoutesWithMiddleware($cloneRoutesWithMiddleware)
->handle();
// Each middleware is only used on a single route so we assert that the count of new routes matches the count of used middleware flags
expect($currentRouteCount())->toEqual($initialRouteCount + count($cloneRoutesWithMiddleware));
})->with([
[[]],
[['duplicate']],
[['clone', 'duplicate']],
]);
test('custom callback can be used for specifying if a route should be cloned', function () {
RouteFacade::get('/home', fn () => true)->name('home');
/** @var CloneRoutesAsTenant $cloneRoutesAction */ /** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get()); $currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount(); $initialRouteCount = $currentRouteCount();
$cloneRoutesAction; // No routes should be cloned
$cloneRoutesAction
->shouldClone(fn (Route $route) => false)
->handle();
// Skip cloning the 'home' route // Expect route count to stay the same because cloning essentially gets turned off
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) {
return;
})->handle();
// Expect route count to stay the same because the 'home' route cloning gets skipped
expect($initialRouteCount)->toEqual($currentRouteCount()); expect($initialRouteCount)->toEqual($currentRouteCount());
// Modify the 'home' route cloning so that a different route is cloned // Only the 'home' route should be cloned
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) { $cloneRoutesAction
RouteFacade::get('/cloned-route', fn () => true)->name('new.home'); ->shouldClone(fn (Route $route) => $route->getName() === 'home')
})->handle(); ->handle();
expect($currentRouteCount())->toEqual($initialRouteCount + 1); expect($currentRouteCount())->toEqual($initialRouteCount + 1);
}); });
test('cloning of specific routes can get skipped', function () { test('custom callbacks can be used for customizing the creation of the cloned routes', function () {
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware('universal')->name($routeName = 'home'); RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
/** @var CloneRoutesAsTenant $cloneRoutesAction */ /** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction
->cloneUsing(function (Route $route) {
RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName());
})->handle();
expect(route('cloned.foo', absolute: false))->toBe('/cloned/foo');
expect(route('cloned.bar', absolute: false))->toBe('/cloned/bar');
pest()->get(route('cloned.foo'))->assertSee('cloned route');
pest()->get(route('cloned.bar'))->assertSee('cloned route');
});
test('the clone action can clone specific routes either using name or route instance', function (bool $cloneRouteByName) {
RouteFacade::get('/foo', fn () => true)->name('foo');
$barRoute = RouteFacade::get('/bar', fn () => true)->name('bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get()); $currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount(); $initialRouteCount = $currentRouteCount();
// Skip cloning the 'home' route /** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction->skipRoute($routeName); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle(); // A route instance or a route name can be passed to cloneRoute()
$cloneRoutesAction->cloneRoute($cloneRouteByName ? $barRoute->getName() : $barRoute)->handle();
// Expect route count to stay the same because the 'home' route cloning gets skipped // Exactly one route should be cloned
expect($initialRouteCount)->toEqual($currentRouteCount()); expect($currentRouteCount())->toEqual($initialRouteCount + 1);
});
test('routes except nonuniversal routes with path id mw are given the tenant flag after cloning', function (array $routeMiddleware, array $globalMiddleware) { expect(RouteFacade::getRoutes()->getByName('tenant.bar'))->not()->toBeNull();
foreach ($globalMiddleware as $middleware) { })->with([
if ($middleware === 'universal') { true,
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]); false,
} else { ]);
app(Kernel::class)->pushMiddleware($middleware);
}
}
$route = RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware($routeMiddleware)
->name($routeName = 'home');
app(CloneRoutesAsTenant::class)->handle();
$clonedRoute = RouteFacade::getRoutes()->getByName('tenant.' . $routeName);
// Non-universal routes with identification middleware are already considered tenant, so they don't get the tenant flag
if (! tenancy()->routeIsUniversal($route) && tenancy()->routeHasIdentificationMiddleware($clonedRoute)) {
expect($clonedRoute->middleware())->not()->toContain('tenant');
} else {
expect($clonedRoute->middleware())->toContain('tenant');
}
})->with('path identification types');
test('routes with the clone flag get cloned without making the routes universal', function ($identificationMiddleware) {
config(['tenancy.identification.path_identification_middleware' => [$identificationMiddleware]]);
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['clone', $identificationMiddleware])
->name($routeName = 'home');
$tenant = Tenant::create();
app(CloneRoutesAsTenant::class)->handle();
$clonedRoute = RouteFacade::getRoutes()->getByName('tenant.' . $routeName);
expect(array_values($clonedRoute->middleware()))->toEqualCanonicalizing(['tenant', $identificationMiddleware]);
// The original route is not accessible
pest()->get(route($routeName))->assertServerError();
pest()->get(route($routeName, ['tenant' => $tenant]))->assertServerError();
// The cloned route is a tenant route
pest()->get(route('tenant.' . $routeName, ['tenant' => $tenant]))->assertSee('Tenancy initialized.');
})->with([InitializeTenancyByPath::class, CustomInitializeTenancyByPath::class]);
test('the clone action prefixes already prefixed routes correctly', function () { test('the clone action prefixes already prefixed routes correctly', function () {
$routes = [ $routes = [
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/home', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('home') ->name('home')
->prefix('prefix'), ->prefix('prefix'),
RouteFacade::get('/leadingAndTrailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/leadingAndTrailingSlash', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('leadingAndTrailingSlash') ->name('leadingAndTrailingSlash')
->prefix('/prefix/'), ->prefix('/prefix/'),
RouteFacade::get('/leadingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/leadingSlash', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('leadingSlash') ->name('leadingSlash')
->prefix('/prefix'), ->prefix('/prefix'),
RouteFacade::get('/trailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/trailingSlash', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('trailingSlash') ->name('trailingSlash')
->prefix('prefix/'), ->prefix('prefix/'),
]; ];
@ -285,14 +211,14 @@ test('the clone action prefixes already prefixed routes correctly', function ()
expect($clonedRouteUrl) expect($clonedRouteUrl)
// Original prefix does not occur in the cloned route's URL // Original prefix does not occur in the cloned route's URL
->not()->toContain("prefix/{$tenant->getTenantKey()}/prefix") ->not()->toContain("prefix/{$tenant->id}/prefix")
->not()->toContain("//prefix") ->not()->toContain("//prefix")
->not()->toContain("prefix//") ->not()->toContain("prefix//")
// Route is prefixed correctly // Instead, the route is prefixed correctly
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}/{$routes[$key]->getName()}"); ->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}");
// The cloned route is accessible // The cloned route is accessible
pest()->get($clonedRouteUrl)->assertSee('Tenancy initialized.'); pest()->get($clonedRouteUrl)->assertOk();
} }
}); });
@ -300,12 +226,12 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('prefix')->group(function () {
RouteFacade::prefix('')->group(function () { RouteFacade::prefix('')->group(function () {
// This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route
RouteFacade::get('/', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('landing'); ->name('landing');
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.') RouteFacade::get('/home', fn () => true)
->middleware(['universal', InitializeTenancyByPath::class]) ->middleware(['clone'])
->name('home'); ->name('home');
}); });
}); });
@ -315,35 +241,99 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
$clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]);
$clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]);
$landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing');
$homeRoute = RouteFacade::getRoutes()->getByName('tenant.home');
expect($landingRoute->uri())->toBe('prefix/{tenant}');
expect($homeRoute->uri())->toBe('prefix/{tenant}/home');
expect($clonedLandingUrl) expect($clonedLandingUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}"); ->toBe("http://localhost/prefix/{$tenant->id}");
expect($clonedHomeRouteUrl) expect($clonedHomeRouteUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}/home"); ->toBe("http://localhost/prefix/{$tenant->id}/home");
}); });
class CustomInitializeTenancyByPath extends InitializeTenancyByPath test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () {
{ // Should NOT be cloned, already has tenant parameter
RouteFacade::get("/{tenant}/route-with-tenant-parameter", fn () => true)
->middleware(['clone'])
->name("tenant.route-with-tenant-parameter");
} // Should NOT be cloned, already has tenant name prefix
RouteFacade::get("/route-with-tenant-name-prefix", fn () => true)
->middleware(['clone'])
->name("tenant.route-with-tenant-name-prefix");
dataset('path identification types', [ // Should NOT be cloned, already has tenant parameter + 'clone' middleware in group
'kernel identification' => [ // 'clone' MW in groups won't be removed (this doesn't cause any issues)
['universal'], // Route middleware RouteFacade::middlewareGroup('group', ['auth', 'clone']);
[InitializeTenancyByPath::class], // Global Global middleware RouteFacade::get("/{tenant}/route-with-clone-in-mw-group", fn () => true)
], ->middleware('group')
'route-level identification' => [ ->name("tenant.route-with-clone-in-mw-group");
['universal', InitializeTenancyByPath::class], // Route middleware
[], // Global middleware // SHOULD be cloned (has clone middleware)
], RouteFacade::get('/foo', fn () => true)
'kernel identification + defaulting to universal routes' => [ ->middleware(['clone'])
[], // Route middleware ->name('foo');
['universal', InitializeTenancyByPath::class], // Global middleware
], // SHOULD be cloned (has nested clone middleware)
'route-level identification + defaulting to universal routes' => [ RouteFacade::get('/bar', fn () => true)
[InitializeTenancyByPath::class], // Route middleware ->middleware(['group'])
['universal'], // Global middleware ->name('bar');
],
]); $cloneAction = app(CloneRoutesAsTenant::class);
$initialRouteCount = count(RouteFacade::getRoutes()->get());
// Run clone action multiple times
$cloneAction->handle();
$firstRunCount = count(RouteFacade::getRoutes()->get());
$cloneAction->handle();
$secondRunCount = count(RouteFacade::getRoutes()->get());
$cloneAction->handle();
$thirdRunCount = count(RouteFacade::getRoutes()->get());
// Two route should have been cloned, and only once
expect($firstRunCount)->toBe($initialRouteCount + 2);
// No new routes on subsequent runs
expect($secondRunCount)->toBe($firstRunCount);
expect($thirdRunCount)->toBe($firstRunCount);
// Verify the correct routes were cloned
expect(RouteFacade::getRoutes()->getByName('tenant.foo'))->toBeInstanceOf(Route::class);
expect(RouteFacade::getRoutes()->getByName('tenant.bar'))->toBeInstanceOf(Route::class);
// Tenant routes were not duplicated
$allRouteNames = collect(RouteFacade::getRoutes()->get())->map->getName()->filter();
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-tenant-parameter'))->count())->toBe(1);
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-tenant-name-prefix'))->count())->toBe(1);
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-clone-in-mw-group'))->count())->toBe(1);
});
test('clone action can be used fluently', function() {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware('clone');
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware('universal');
$cloneAction = app(CloneRoutesAsTenant::class);
// Clone foo route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo');
// Clone bar route
$cloneAction->cloneRoutesWithMiddleware(['universal'])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
// Clone baz route
$cloneAction->cloneRoute('baz')->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar', 'tenant.baz');
});

View file

@ -3,10 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
Route::group([ Route::group([

View file

@ -26,6 +26,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {

View file

@ -24,6 +24,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
config([ config([

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
InitializeTenancyByDomain::$onFail = null; InitializeTenancyByDomain::$onFail = null;

View file

@ -24,6 +24,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithRouteMiddleware; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithRouteMiddleware;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
config()->set([ config()->set([
@ -343,9 +344,9 @@ test('the tenant parameter is only removed from tenant routes when using path id
->middleware('tenant') ->middleware('tenant')
->name('tenant-route'); ->name('tenant-route');
RouteFacade::get($pathIdentification ? '/universal-route' : '/universal-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter']) RouteFacade::get($pathIdentification ? '/cloned-route' : '/cloned-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter'])
->middleware('universal') ->middleware('clone')
->name('universal-route'); ->name('cloned-route');
/** @var CloneRoutesAsTenant */ /** @var CloneRoutesAsTenant */
$cloneRoutesAction = app(CloneRoutesAsTenant::class); $cloneRoutesAction = app(CloneRoutesAsTenant::class);
@ -363,8 +364,8 @@ test('the tenant parameter is only removed from tenant routes when using path id
$response = pest()->get($tenantKey . '/tenant-route')->assertOk(); $response = pest()->get($tenantKey . '/tenant-route')->assertOk();
expect((bool) $response->getContent())->toBeFalse(); expect((bool) $response->getContent())->toBeFalse();
// The tenant parameter gets removed from the cloned universal route // The tenant parameter gets removed from the cloned route
$response = pest()->get($tenantKey . '/universal-route')->assertOk(); $response = pest()->get($tenantKey . '/cloned-route')->assertOk();
expect((bool) $response->getContent())->toBeFalse(); expect((bool) $response->getContent())->toBeFalse();
} else { } else {
// Tenant parameter is not removed from tenant routes using other kernel identification MW // Tenant parameter is not removed from tenant routes using other kernel identification MW
@ -373,12 +374,12 @@ test('the tenant parameter is only removed from tenant routes when using path id
$response = pest()->get("http://{$domain}/{$tenantKey}/tenant-route")->assertOk(); $response = pest()->get("http://{$domain}/{$tenantKey}/tenant-route")->assertOk();
expect((bool) $response->getContent())->toBeTrue(); expect((bool) $response->getContent())->toBeTrue();
// The tenant parameter does not get removed from the universal route when accessing it through the central domain // The tenant parameter does not get removed from the cloned route when accessing it through the central domain
$response = pest()->get("http://localhost/universal-route/$tenantKey")->assertOk(); $response = pest()->get("http://localhost/cloned-route/$tenantKey")->assertOk();
expect((bool) $response->getContent())->toBeTrue(); expect((bool) $response->getContent())->toBeTrue();
// The tenant parameter gets removed from the universal route when accessing it through the tenant domain // The tenant parameter gets removed from the cloned route when accessing it through the tenant domain
$response = pest()->get("http://{$domain}/universal-route")->assertOk(); $response = pest()->get("http://{$domain}/cloned-route")->assertOk();
expect((bool) $response->getContent())->toBeFalse(); expect((bool) $response->getContent())->toBeFalse();
} }
} else { } else {

View file

@ -19,6 +19,7 @@ use Stancl\Tenancy\Events\BootstrappingTenancy;
use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
FooListener::$shouldQueue = false; FooListener::$shouldQueue = false;

View file

@ -18,14 +18,9 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('sqlite ATTACH statements can be blocked', function (bool $disallow) { test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
try {
readlink(base_path('vendor'));
} catch (\Throwable) {
symlink(base_path('vendor'), '/var/www/html/vendor');
}
if (php_uname('m') == 'aarch64') { if (php_uname('m') == 'aarch64') {
// Escape testbench prison. Can't hardcode /var/www/html/extensions/... here // Escape testbench prison. Can't hardcode /var/www/html/extensions/... here
// since GHA doesn't mount the filesystem on the container's workdir // since GHA doesn't mount the filesystem on the container's workdir

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Features\CrossDomainRedirect; use Stancl\Tenancy\Features\CrossDomainRedirect;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('tenant redirect macro replaces only the hostname', function () { test('tenant redirect macro replaces only the hostname', function () {
config([ config([

View file

@ -9,6 +9,7 @@ use Stancl\Tenancy\Features\TenantConfig;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
afterEach(function () { afterEach(function () {
TenantConfig::$storageToConfigMap = []; TenantConfig::$storageToConfigMap = [];

View file

@ -10,6 +10,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function() { beforeEach(function() {
config(['mail.default' => 'smtp']); config(['mail.default' => 'smtp']);

View file

@ -8,6 +8,7 @@ use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () { beforeEach(function () {

View file

@ -11,7 +11,8 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\CreateTenantConnection; use Stancl\Tenancy\Listeners\CreateTenantConnection;
use Stancl\Tenancy\Listeners\UseCentralConnection; use Stancl\Tenancy\Listeners\UseCentralConnection;
use Stancl\Tenancy\Listeners\UseTenantConnection; use Stancl\Tenancy\Listeners\UseTenantConnection;
use \Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('manual tenancy initialization works', function () { test('manual tenancy initialization works', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader; use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
InitializeTenancyByOriginHeader::$onFail = null; InitializeTenancyByOriginHeader::$onFail = null;
@ -35,6 +36,12 @@ test('origin identification works', function () {
->withHeader('Origin', 'foo.localhost') ->withHeader('Origin', 'foo.localhost')
->post('home') ->post('home')
->assertSee($tenant->id); ->assertSee($tenant->id);
// Test with a full URL - not just a hostname
pest()
->withHeader('Origin', 'https://foo.localhost')
->post('home')
->assertSee($tenant->id);
}); });
test('tenant routes are not accessible on central domains while using origin identification', function () { test('tenant routes are not accessible on central domains while using origin identification', function () {

View file

@ -3,15 +3,30 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Contracts\Http\Kernel; use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException; use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\User;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
// Make sure the tenant parameter is set to 'tenant' // Make sure the tenant parameter is set to 'tenant'
@ -34,6 +49,11 @@ beforeEach(function () {
}); });
}); });
afterEach(function () {
InitializeTenancyByPath::$onFail = null;
Tenant::$extraCustomColumns = [];
});
test('tenant can be identified by path', function () { test('tenant can be identified by path', function () {
Tenant::create([ Tenant::create([
'id' => 'acme', 'id' => 'acme',
@ -149,6 +169,7 @@ test('central route can have a parameter with the same name as the tenant parame
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
$tenantKey = Tenant::create()->getTenantKey(); $tenantKey = Tenant::create()->getTenantKey();
// The route is flagged as central (while using kernel identification) so the {team} parameter should not be used for tenancy initialization
Route::get('/central/route/{team}/{a}/{b}', function ($team, $a, $b) { Route::get('/central/route/{team}/{a}/{b}', function ($team, $a, $b) {
return "$a + $b + $team"; return "$a + $b + $team";
})->middleware('central')->name('central-route'); })->middleware('central')->name('central-route');
@ -184,8 +205,6 @@ test('the tenant model column can be customized in the config', function () {
$this->withoutExceptionHandling(); $this->withoutExceptionHandling();
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
Tenant::$extraCustomColumns = []; // static property reset
}); });
test('the tenant model column can be customized in the route definition', function () { test('the tenant model column can be customized in the route definition', function () {
@ -217,8 +236,6 @@ test('the tenant model column can be customized in the route definition', functi
// Binding field defined // Binding field defined
pest()->get('/acme/bar')->assertSee($tenant->getTenantKey()); pest()->get('/acme/bar')->assertSee($tenant->getTenantKey());
expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class); expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
Tenant::$extraCustomColumns = []; // static property reset
}); });
test('any extra model column needs to be whitelisted', function () { test('any extra model column needs to be whitelisted', function () {
@ -242,6 +259,39 @@ test('any extra model column needs to be whitelisted', function () {
// After whitelisting the column it works // After whitelisting the column it works
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
});
Tenant::$extraCustomColumns = []; // static property reset
test('route model binding works with path identification', function() {
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class, MigrateDatabase::class,
])->send(fn (TenantCreated $event) => $event->tenant)->toListener());
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
$tenant = Tenant::create();
$this->withoutExceptionHandling();
// Importantly, the route must have the 'web' middleware group, or SubstituteBindings directly
Route::get('/{tenant}/foo/{user}', fn (User $user) => $user->name)->middleware([InitializeTenancyByPath::class, 'web']);
Route::get('/{tenant}/bar/{user}', fn (User $user) => $user->name)->middleware([InitializeTenancyByPath::class, SubstituteBindings::class]);
$user = $tenant->run(fn () => User::create(['name' => 'John Doe', 'email' => 'john@doe.com', 'password' => 'foobar']));
pest()->get("/{$tenant->id}/foo/{$user->id}")->assertSee("John Doe");
tenancy()->end();
pest()->get("/{$tenant->id}/bar/{$user->id}")->assertSee("John Doe");
tenancy()->end();
// If SubstituteBindings comes BEFORE tenancy middleware and middleware priority is not set, route model binding is NOT expected to work correctly
// Since SubstituteBindings runs first, it tries to query the central database instead of the tenant database (which fails with a QueryException in this case)
Route::get('/{tenant}/baz/{user}', fn (User $user) => $user->name ?: 'No user')->middleware([SubstituteBindings::class, InitializeTenancyByPath::class]);
expect(fn () => pest()->get("/{$tenant->id}/baz/{$user->id}"))->toThrow(QueryException::class);
tenancy()->end();
// If SubstituteBindings is NOT USED AT ALL, we simply get an empty User instance
Route::get('/{tenant}/xyz/{user}', fn (User $user) => $user->name ?: 'No user')->middleware([InitializeTenancyByPath::class]);
pest()->get("/{$tenant->id}/xyz/{$user->id}")->assertSee('No user');
}); });

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Events\PullingPendingTenant;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('tenants are correctly identified as pending', function (){ test('tenants are correctly identified as pending', function (){
Tenant::createPending(); Tenant::createPending();

View file

@ -1,5 +1,7 @@
<?php <?php
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\Tests\TestCase; use Stancl\Tenancy\Tests\TestCase;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -8,14 +10,14 @@ use Stancl\Tenancy\Events\TenantCreated;
uses(TestCase::class)->in(__DIR__); uses(TestCase::class)->in(__DIR__);
function pest(): TestCase
{
return Pest\TestSuite::getInstance()->test;
}
function withTenantDatabases() function withTenantDatabases()
{ {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->toListener()); })->toListener());
} }
function pest(): TestCase
{
return \Pest\TestSuite::getInstance()->test;
}

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware; use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware;
use function Stancl\Tenancy\Tests\pest;
test('correct routes are accessible in route-level identification', function (RouteMode $defaultRouteMode) { test('correct routes are accessible in route-level identification', function (RouteMode $defaultRouteMode) {
config()->set([ config()->set([

View file

@ -22,16 +22,14 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\PersistentQueueTenancyBootstrapper;
use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\Listeners\QueueableListener;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () { beforeEach(function () {
$this->mockConsoleOutput = false;
config([ config([
'tenancy.bootstrappers' => [ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
QueueTenancyBootstrapper::class,
DatabaseTenancyBootstrapper::class,
],
'queue.default' => 'redis', 'queue.default' => 'redis',
]); ]);
@ -45,7 +43,22 @@ afterEach(function () {
pest()->valuestore->flush(); pest()->valuestore->flush();
}); });
test('tenant id is passed to tenant queues', function () { dataset('queue_bootstrappers', [
QueueTenancyBootstrapper::class,
PersistentQueueTenancyBootstrapper::class,
]);
function withQueueBootstrapper(string $class) {
config(['tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
$class,
]]);
$class::__constructStatic(app());
}
test('tenant id is passed to tenant queues', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases(); withTenantDatabases();
config(['queue.default' => 'sync']); config(['queue.default' => 'sync']);
@ -61,9 +74,10 @@ test('tenant id is passed to tenant queues', function () {
Event::assertDispatched(JobProcessing::class, function ($event) { Event::assertDispatched(JobProcessing::class, function ($event) {
return $event->job->payload()['tenant_id'] === tenant('id'); return $event->job->payload()['tenant_id'] === tenant('id');
}); });
}); })->with('queue_bootstrappers');
test('tenant id is not passed to central queues', function () { test('tenant id is not passed to central queues', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases(); withTenantDatabases();
$tenant = Tenant::create(); $tenant = Tenant::create();
@ -82,9 +96,10 @@ test('tenant id is not passed to central queues', function () {
Event::assertDispatched(JobProcessing::class, function ($event) { Event::assertDispatched(JobProcessing::class, function ($event) {
return ! isset($event->job->payload()['tenant_id']); return ! isset($event->job->payload()['tenant_id']);
}); });
}); })->with('queue_bootstrappers');
test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) { test('tenancy is initialized inside queues', function (bool $shouldEndTenancy, string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases(); withTenantDatabases();
withFailedJobs(); withFailedJobs();
@ -117,7 +132,7 @@ test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) {
$tenant->run(function () use ($user) { $tenant->run(function () use ($user) {
expect($user->fresh()->name)->toBe('Bar'); expect($user->fresh()->name)->toBe('Bar');
}); });
})->with([true, false]); })->with([true, false])->with('queue_bootstrappers');
test('changing the shouldQueue static property in parent class affects child classes unless the property is redefined', function () { test('changing the shouldQueue static property in parent class affects child classes unless the property is redefined', function () {
// Parent $shouldQueue is true // Parent $shouldQueue is true
@ -142,7 +157,8 @@ test('changing the shouldQueue static property in parent class affects child cla
expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse(); expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse();
}); });
test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy) { test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy, string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withFailedJobs(); withFailedJobs();
withTenantDatabases(); withTenantDatabases();
@ -189,9 +205,10 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
$tenant->run(function () use ($user) { $tenant->run(function () use ($user) {
expect($user->fresh()->name)->toBe('Bar'); expect($user->fresh()->name)->toBe('Bar');
}); });
})->with([true, false]); })->with([true, false])->with('queue_bootstrappers');
test('the tenant used by the job doesnt change when the current tenant changes', function () { test('the tenant used by the job doesnt change when the current tenant changes', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases(); withTenantDatabases();
$tenant1 = Tenant::create(); $tenant1 = Tenant::create();
@ -208,26 +225,11 @@ test('the tenant used by the job doesnt change when the current tenant changes',
pest()->artisan('queue:work --once'); pest()->artisan('queue:work --once');
expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant1->getTenantKey()); expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant1->getTenantKey());
}); })->with('queue_bootstrappers');
test('tenant connections do not persist after tenant jobs get processed', function() {
withTenantDatabases();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
dispatch(new TestJob(pest()->valuestore));
tenancy()->end();
pest()->artisan('queue:work --once');
expect(collect(DB::select('SHOW FULL PROCESSLIST'))->pluck('db'))->not()->toContain($tenant->database()->getName());
});
// Regression test for #1277 // Regression test for #1277
test('dispatching a job from a tenant run arrow function dispatches it immediately', function () { test('dispatching a job from a tenant run arrow function dispatches it immediately', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases(); withTenantDatabases();
$tenant = Tenant::create(); $tenant = Tenant::create();
@ -241,7 +243,7 @@ test('dispatching a job from a tenant run arrow function dispatches it immediate
expect(tenant())->toBe(null); expect(tenant())->toBe(null);
expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant->getTenantKey()); expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant->getTenantKey());
}); })->with('queue_bootstrappers');
function createValueStore(): void function createValueStore(): void
{ {

View file

@ -17,8 +17,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TraitRLSManager::$excludedModels = [Article::class]; TraitRLSManager::$excludedModels = [Article::class];
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc']; TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
@ -78,6 +80,10 @@ beforeEach(function () {
}); });
}); });
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
// Regression test for https://github.com/archtechx/tenancy/pull/1280 // Regression test for https://github.com/archtechx/tenancy/pull/1280
test('rls command doesnt fail when a view is in the database', function (string $manager) { test('rls command doesnt fail when a view is in the database', function (string $manager) {
DB::statement(" DB::statement("
@ -183,7 +189,9 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class, TraitRLSManager::class,
]); ]);
test('queries will stop working when the tenant session variable is not set', function(string $manager) { test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
config(['tenancy.rls.manager' => $manager]); config(['tenancy.rls.manager' => $manager]);
$sessionVariableName = config('tenancy.rls.session_variable_name'); $sessionVariableName = config('tenancy.rls.session_variable_name');
@ -215,7 +223,4 @@ test('queries will stop working when the tenant session variable is not set', fu
INSERT INTO posts (text, tenant_id, author_id) INSERT INTO posts (text, tenant_id, author_id)
VALUES ('post2', ?, ?) VALUES ('post2', ?, ?)
SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class); SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class);
})->with([ })->with([TableRLSManager::class, TraitRLSManager::class])->with([true, false]);
TableRLSManager::class,
TraitRLSManager::class,
]);

View file

@ -15,12 +15,16 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Schema\ForeignIdColumnDefinition;
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies; use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException; use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
beforeEach(function () { beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TableRLSManager::$scopeByDefault = true; TableRLSManager::$scopeByDefault = true;
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
@ -106,6 +110,10 @@ beforeEach(function () {
}); });
}); });
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
test('correct rls policies get created with the correct hash using table manager', function() { test('correct rls policies get created with the correct hash using table manager', function() {
$manager = app(config('tenancy.rls.manager')); $manager = app(config('tenancy.rls.manager'));
@ -158,13 +166,22 @@ test('correct rls policies get created with the correct hash using table manager
} }
}); });
test('queries are correctly scoped using RLS', function() { test('queries are correctly scoped using RLS', function (
bool $forceRls,
bool $commentConstraint,
) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// 3-levels deep relationship // 3-levels deep relationship
Schema::create('notes', function (Blueprint $table) { Schema::create('notes', function (Blueprint $table) use ($commentConstraint) {
$table->id(); $table->id();
$table->string('text')->default('foo'); $table->string('text')->default('foo');
// no rls comment needed, $scopeByDefault is set to true // no rls comment needed, $scopeByDefault is set to true
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->constrained('comments'); if ($commentConstraint) {
$table->foreignId('comment_id')->comment('rls comments.id');
} else {
$table->foreignId('comment_id')->constrained('comments');
}
$table->timestamps(); $table->timestamps();
}); });
@ -180,9 +197,9 @@ test('queries are correctly scoped using RLS', function() {
$post1 = Post::create([ $post1 = Post::create([
'text' => 'first post', 'text' => 'first post',
'tenant_id' => $tenant1->getTenantKey(), 'tenant_id' => $tenant1->id,
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->getTenantKey()])->id, 'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->id])->id,
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->getTenantKey()])->id, 'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->id])->id,
]); ]);
$post1Comment = Comment::create(['text' => 'first comment', 'post_id' => $post1->id]); $post1Comment = Comment::create(['text' => 'first comment', 'post_id' => $post1->id]);
@ -193,9 +210,9 @@ test('queries are correctly scoped using RLS', function() {
$post2 = Post::create([ $post2 = Post::create([
'text' => 'second post', 'text' => 'second post',
'tenant_id' => $tenant2->getTenantKey(), 'tenant_id' => $tenant2->id,
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->getTenantKey()])->id, 'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->id])->id,
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->getTenantKey()])->id 'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->id])->id
]); ]);
$post2Comment = Comment::create(['text' => 'second comment', 'post_id' => $post2->id]); $post2Comment = Comment::create(['text' => 'second comment', 'post_id' => $post2->id]);
@ -311,7 +328,7 @@ test('queries are correctly scoped using RLS', function() {
expect(Note::count())->toBe(1); expect(Note::count())->toBe(1);
// Directly inserting records to other tenant's tables should fail (insufficient privilege error new row violates row-level security policy) // Directly inserting records to other tenant's tables should fail (insufficient privilege error new row violates row-level security policy)
expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->getTenantKey()}')")) expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->id}')"))
->toThrow(QueryException::class); ->toThrow(QueryException::class);
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})")) expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
@ -319,95 +336,90 @@ test('queries are correctly scoped using RLS', function() {
expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})")) expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})"))
->toThrow(QueryException::class); ->toThrow(QueryException::class);
}); })->with(['forceRls is true' => true, 'forceRls is false' => false])
->with(['comment constraint' => true, 'foreign key constraint' => false]);
test('table rls manager generates relationship trees with tables related to the tenants table', function (bool $scopeByDefault) { test('table rls manager generates shortest paths that lead to the tenants table correctly', function (bool $scopeByDefault) {
TableRLSManager::$scopeByDefault = $scopeByDefault; TableRLSManager::$scopeByDefault = $scopeByDefault;
// Only related to the tenants table through nullable columns (directly through tenant_id and indirectly through post_id)
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->nullable()->comment('rls')->constrained();
// No 'rls' comment should get excluded from path generation when using explicit scoping
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
/** @var TableRLSManager $manager */ /** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class); $manager = app(TableRLSManager::class);
$expectedTrees = [ $expectedShortestPaths = [
'authors' => [ 'authors' => [
// Directly related to tenants
'tenant_id' => [
[ [
[ 'localColumn' => 'tenant_id',
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
'nullable' => false,
]
],
],
],
'comments' => [
// Tree starting from the post_id foreign key
'post_id' => [
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
],
],
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
],
],
], ],
], ],
'posts' => [ 'posts' => [
// Category tree gets excluded because the category table is related to the tenant table
// only through a column with the 'no-rls' comment
'author_id' => [
[ [
[ 'localColumn' => 'author_id',
'foreignKey' => 'author_id',
'foreignTable' => 'authors', 'foreignTable' => 'authors',
'foreignId' => 'id', 'foreignColumn' => 'id',
'nullable' => false,
], ],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
'nullable' => false,
]
], ],
], ],
'tenant_id' => [ 'comments' => [
[ [
'localColumn' => 'post_id',
'foreignTable' => 'posts',
'foreignColumn' => 'id',
],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
'nullable' => true, ],
] ],
] // When scoping by default is enabled (implicit scoping),
// the shortest path from the ratings table leads directly through tenant_id.
// When scoping by default is disabled (explicit scoping),
// the shortest path leads through post_id.
'ratings' => $scopeByDefault ? [
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
] : [
[
'localColumn' => 'post_id',
'foreignTable' => 'posts',
'foreignColumn' => 'id',
],
[
'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
], ],
], ],
// Articles table is ignored because it's not related to the tenant table in any way // Articles table is ignored because it's not related to the tenant table in any way
@ -415,124 +427,156 @@ test('table rls manager generates relationship trees with tables related to the
// Categories table is ignored because of the 'no-rls' comment on the tenant_id column // Categories table is ignored because of the 'no-rls' comment on the tenant_id column
]; ];
expect($manager->generateTrees())->toEqual($expectedTrees); expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
$expectedShortestPaths = [ // Add non-nullable comment_id comment constraint
'authors' => [ Schema::table('ratings', function (Blueprint $table) {
$table->string('comment_id')->comment('rls comments.id');
// Nullable constraint with a non-RLS comment.
// Skipped when scopeByDefault is false,
// not ignored when scopeByDefault is true, but still,
// not preferred since comment_id is valid and non-nullable.
$table->foreignId('author_id')->nullable()->comment('random comment')->constrained('authors');
});
// Non-nullable paths are preferred over nullable paths
$expectedShortestPaths['ratings'] = [
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'comment_id',
'foreignTable' => 'tenants', 'foreignTable' => 'comments',
'foreignId' => 'id', 'foreignColumn' => 'id',
],
],
'posts' => [
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
], ],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'post_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
'comments' => [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts', 'foreignTable' => 'posts',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
[ [
'foreignKey' => 'author_id', // Importantly, the best path goes through authors
// since ratings -> posts is nullable, as well as
// posts -> tenants directly (without going through
// authors first).
'localColumn' => 'author_id',
'foreignTable' => 'authors', 'foreignTable' => 'authors',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
],
], ],
]; ];
// The shortest paths should now include a path for the ratings table
// that leads through comment_id instead of tenant_id since comment_id
// is not nullable (and therefore preferable) unlike path_id or tenant_id
// even if the latter paths are shorter.
expect($manager->shortestPaths())->toEqual($expectedShortestPaths); expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
})->with([true, false]);
// Only related to the tenants table through nullable columns tenant_id and indirectly through post_id // https://github.com/archtechx/tenancy/pull/1293
Schema::create('ratings', function (Blueprint $table) { test('forceRls prevents even the table owner from querying his own tables if he doesnt have a BYPASSRLS permission', function (bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// Drop all tables created in beforeEach
DB::statement("DROP TABLE authors, categories, posts, comments, reactions, articles;");
// Create a new user so we have full control over the permissions.
// We explicitly set bypassRls to false.
[$username, $password] = createPostgresUser('administrator', bypassRls: false);
config(['database.connections.central' => array_merge(config('database.connections.pgsql'), [
'username' => $username,
'password' => $password,
])]);
DB::reconnect();
// This table is owned by the newly created 'administrator' user
Schema::create('orders', function (Blueprint $table) {
$table->id(); $table->id();
$table->integer('stars')->default(0); $table->string('name');
$table->unsignedBigInteger('post_id')->nullable()->comment('rls'); $table->string('tenant_id')->comment('rls');
$table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade');
// No 'rls' comment should get excluded from full trees when using explicit scoping
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps(); $table->timestamps();
}); });
// The shortest paths should include a path for the ratings table $tenant1 = Tenant::create();
// That leads through tenant_id when scoping by default is enabled, that's the shortest path
// When scoping by default is disabled, the shortest path leads through post_id
// This behavior is handled by the manager's generateTrees() method, which is called by shortestPaths()
$shortestPaths = $manager->shortestPaths();
$expectedShortestPath = $scopeByDefault ? [ // Create RLS policy for the orders table
[ pest()->artisan('tenants:rls');
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
] : [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
];
expect($shortestPaths['ratings'])->toBe($expectedShortestPath); $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
// Add non-nullable comment_id foreign key // We are still using the 'administrator' user - owner of the orders table
Schema::table('ratings', function (Blueprint $table) {
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments'); if ($forceRls) {
// RLS is forced, so by default, not even the table owner should be able to query the table protected by the RLS policy.
// The RLS policy is not being bypassed, 'unrecognized configuration parameter' means
// that the my.current_tenant session variable isn't set -- the RLS policy is *still* being enforced.
expect(fn () => Order::first())->toThrow(QueryException::class, 'unrecognized configuration parameter "my.current_tenant"');
} else {
// RLS is not forced, so the table owner should be able to query the table, bypassing the RLS policy
expect(Order::first())->not()->toBeNull();
}
})->with([true, false]);
test('users with BYPASSRLS privilege can bypass RLS regardless of forceRls setting', function (bool $forceRls, bool $bypassRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// Drop all tables created in beforeEach
DB::statement("DROP TABLE authors, categories, posts, comments, reactions, articles;");
// Create a new user so we have control over his BYPASSRLS permission
// and use that as the new central connection user
[$username, $password] = createPostgresUser('administrator', 'password', $bypassRls);
config(['database.connections.central' => array_merge(config('database.connections.pgsql'), [
'username' => $username,
'password' => $password,
])]);
DB::reconnect();
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id')->comment('rls');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
}); });
// Non-nullable paths are preferred over nullable paths $tenant1 = Tenant::create();
// The shortest paths should include a path for the ratings table
// That leads through comment_id instead of tenant_id
$shortestPaths = $manager->shortestPaths();
expect($shortestPaths['ratings'])->toBe([ // Create RLS policy for the orders table
[ pest()->artisan('tenants:rls');
'foreignKey' => 'comment_id',
'foreignTable' => 'comments', $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
'foreignId' => 'id',
], // We are still using the 'administrator' user
[
'foreignKey' => 'post_id', if ($bypassRls) {
'foreignTable' => 'posts', // Users with BYPASSRLS can always query tables regardless of forceRls setting
'foreignId' => 'id', expect(Order::count())->toBe(1);
], expect(Order::first()->name)->toBe('order1');
[ } else {
'foreignKey' => 'author_id', // Users without BYPASSRLS are subject to RLS policies even if they're table owners when forceRls is true
'foreignTable' => 'authors', // OR they can bypass as table owners (when forceRls=false)
'foreignId' => 'id', if ($forceRls) {
], // Even table owners need session variable -- this means RLS was NOT bypassed
[ expect(fn () => Order::first())->toThrow(QueryException::class, 'unrecognized configuration parameter "my.current_tenant"');
'foreignKey' => 'tenant_id', } else {
'foreignTable' => 'tenants', // Table owners can bypass RLS automatically when forceRls is false
'foreignId' => 'id', expect(Order::count())->toBe(1);
], expect(Order::first()->name)->toBe('order1');
]); }
})->with([true, false]); }
})->with([true, false])->with([true, false]);
test('table rls manager generates queries correctly', function() { test('table rls manager generates queries correctly', function() {
expect(array_values(app(TableRLSManager::class)->generateQueries()))->toEqualCanonicalizing([ expect(array_values(app(TableRLSManager::class)->generateQueries()))->toEqualCanonicalizing([
@ -569,38 +613,38 @@ test('table rls manager generates queries correctly', function() {
$paths = [ $paths = [
'primaries' => [ 'primaries' => [
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
], ],
'secondaries' => [ 'secondaries' => [
[ [
'foreignKey' => 'primary_id', 'localColumn' => 'primary_id',
'foreignTable' => 'primaries', 'foreignTable' => 'primaries',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
], ],
'foo' => [ 'foo' => [
[ [
'foreignKey' => 'secondary_id', 'localColumn' => 'secondary_id',
'foreignTable' => 'secondaries', 'foreignTable' => 'secondaries',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
[ [
'foreignKey' => 'primary_id', 'localColumn' => 'primary_id',
'foreignTable' => 'primaries', 'foreignTable' => 'primaries',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
[ [
'foreignKey' => 'tenant_id', 'localColumn' => 'tenant_id',
'foreignTable' => 'tenants', 'foreignTable' => 'tenants',
'foreignId' => 'id', 'foreignColumn' => 'id',
], ],
], ],
]; ];
@ -636,19 +680,180 @@ test('table rls manager generates queries correctly', function() {
); );
}); });
test('table manager throws an exception when encountering a recursive relationship', function() { test('table manager throws an exception when the only available paths lead through recursive relationships', function (bool $useCommentConstraints) {
// We test recursive relations using both foreign key constraints and comment constraints
$makeConstraint = function (ForeignIdColumnDefinition $relation, $table, $column) use ($useCommentConstraints) {
if ($useCommentConstraints) {
$relation->comment("rls $table.$column");
} else {
$relation->constrained($table, $column);
}
};
Schema::create('recursive_posts', function (Blueprint $table) { Schema::create('recursive_posts', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls');
}); });
Schema::create('recursive_comments', function (Blueprint $table) {
$table->id();
});
Schema::table('recursive_posts', function (Blueprint $table) use ($makeConstraint) {
$makeConstraint($table->foreignId('highlighted_comment_id')->nullable(), 'recursive_comments', 'id');
});
Schema::table('recursive_comments', function (Blueprint $table) use ($makeConstraint) {
$makeConstraint($table->foreignId('recursive_post_id'), 'recursive_posts', 'id');
});
expect(fn () => app(TableRLSManager::class)->shortestPaths())->toThrow(RecursiveRelationshipException::class);
Schema::table('recursive_comments', function (Blueprint $table) use ($makeConstraint, $useCommentConstraints) {
// Add another recursive relationship to demonstrate a more complex case
$makeConstraint($table->foreignId('related_post_id'), 'recursive_posts', 'id');
// Add a foreign key to the current table (= self-referencing constraint)
$makeConstraint($table->foreignId('parent_comment_id'), 'recursive_comments', 'id');
// Add tenant_id to break the recursion - RecursiveRelationshipException should not be thrown
// We cannot use $makeConstraint() here since tenant_id is a string column
if ($useCommentConstraints) {
$table->string('tenant_id')->comment('rls tenants.id');
} else {
$table->string('tenant_id')->comment('rls')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
}
});
// Doesn't throw an exception anymore
$shortestPaths = app(TableRLSManager::class)->shortestPaths();
// Generated paths include both the recursive_posts and the recursive_comments tables
// because they actually lead to the tenants table now.
//
// recursive_comments has a direct path to tenants, recursive_posts has a path
// to tenants through recursive_comments
expect(array_keys($shortestPaths))->toContain('recursive_posts', 'recursive_comments');
})->with([true, false]);
test('table manager ignores recursive relationship if the foreign key responsible for the recursion has no-rls comment', function() {
Schema::create('recursive_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('highlighted_comment_id')->nullable()->comment('no-rls')->constrained('comments');
});
// Add a foreign key constraint to the comments table to introduce a recursive relationship
// Note that the comments table still has the post_id foreign key that leads to the tenants table
Schema::table('comments', function (Blueprint $table) { Schema::table('comments', function (Blueprint $table) {
$table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls'); $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts');
}); });
expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class); // No exception thrown because
// the highlighted_comment_id foreign key has a no-rls comment
$shortestPaths = app(TableRLSManager::class)->shortestPaths();
expect(array_keys($shortestPaths))
->toContain('posts', 'comments')
// Shortest paths do not include the recursive_posts table
// because it has a 'no-rls' comment on its only foreign key
->not()->toContain('recursive_posts');
}); });
test('table manager can generate paths leading through comment constraint columns', function() {
// Drop extra tables created in beforeEach
Schema::dropIfExists('reactions');
Schema::dropIfExists('comments');
Schema::dropIfExists('posts');
Schema::dropIfExists('authors');
Schema::create('non_constrained_users', function (Blueprint $table) {
$table->id();
$table->string('tenant_id')->comment('rls tenants.id'); // Comment constraint
});
Schema::create('non_constrained_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->comment('rls non_constrained_users.id'); // Comment constraint
});
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
$expectedPaths = [
'non_constrained_posts' => [
[
'localColumn' => 'author_id',
'foreignTable' => 'non_constrained_users',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
'non_constrained_users' => [
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
];
expect($manager->shortestPaths())->toEqual($expectedPaths);
});
test('table manager throws an exception when comment constraint is incorrect', function(string $comment, string $exceptionMessage) {
Schema::create('non_constrained_users', function (Blueprint $table) use ($comment) {
$table->id();
$table->string('tenant_id')->comment($comment); // Invalid comment constraint
});
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
expect(fn () => $manager->shortestPaths())->toThrow(
RLSCommentConstraintException::class,
$exceptionMessage
);
})->with([
['rls ', 'Malformed comment constraint on non_constrained_users'], // Missing table.column
['rls tenants', 'Malformed comment constraint on non_constrained_users'], // Missing column part
['rls tenants.', 'Malformed comment constraint on non_constrained_users'], // Missing column part
['rls .id', 'Malformed comment constraint on non_constrained_users'], // Missing table part
['rls tenants.foreign.id', 'Malformed comment constraint on non_constrained_users'], // Too many parts
['rls nonexistent-table.id', 'references non-existent table'],
['rls tenants.nonexistent-column', 'references non-existent column'],
]);
function createPostgresUser(string $username, string $password = 'password', bool $bypassRls = false): array
{
try {
DB::statement("DROP OWNED BY {$username};");
} catch (\Throwable) {}
DB::statement("DROP USER IF EXISTS {$username};");
DB::statement("CREATE USER {$username} WITH ENCRYPTED PASSWORD '{$password}'");
DB::statement("ALTER USER {$username} CREATEDB");
DB::statement("ALTER USER {$username} CREATEROLE");
// Grant BYPASSRLS privilege if requested
if ($bypassRls) {
DB::statement("ALTER USER {$username} BYPASSRLS");
}
// Grant privileges to the new central user
DB::statement("GRANT ALL PRIVILEGES ON DATABASE main to {$username}");
DB::statement("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {$username}");
DB::statement("GRANT ALL ON SCHEMA public TO {$username}");
DB::statement("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {$username}");
DB::statement("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {$username}");
return [$username, $password];
}
class Post extends Model class Post extends Model
{ {
protected $guarded = []; protected $guarded = [];
@ -701,3 +906,8 @@ class Author extends Model
{ {
protected $guarded = []; protected $guarded = [];
} }
class Order extends Model
{
protected $guarded = [];
}

View file

@ -25,8 +25,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel; use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TraitRLSManager::$implicitRLS = true; TraitRLSManager::$implicitRLS = true;
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc']; TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
TraitRLSManager::$excludedModels = [Article::class]; TraitRLSManager::$excludedModels = [Article::class];
@ -77,6 +79,10 @@ beforeEach(function () {
}); });
}); });
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
test('correct rls policies get created with the correct hash using trait manager', function () { test('correct rls policies get created with the correct hash using trait manager', function () {
$manager = app(TraitRLSManager::class); $manager = app(TraitRLSManager::class);
@ -148,7 +154,8 @@ test('global scope is not applied when using rls with single db traits', functio
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse(); expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
}); });
test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS) { test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
TraitRLSManager::$implicitRLS = $implicitRLS; TraitRLSManager::$implicitRLS = $implicitRLS;
$postModel = $implicitRLS ? NonRLSPost::class : Post::class; $postModel = $implicitRLS ? NonRLSPost::class : Post::class;
@ -262,10 +269,7 @@ test('queries are correctly scoped using RLS with trait rls manager', function (
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})")) expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
->toThrow(QueryException::class); ->toThrow(QueryException::class);
})->with([ })->with([true, false])->with([true, false]);
true,
false
]);
test('trait rls manager generates queries correctly', function() { test('trait rls manager generates queries correctly', function() {
/** @var TraitRLSManager $manager */ /** @var TraitRLSManager $manager */

View file

@ -2,10 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
config([ config([
@ -14,45 +17,90 @@ beforeEach(function () {
], ],
]); ]);
InitializeTenancyByRequestData::$header = 'X-Tenant'; Route::middleware([InitializeTenancyByRequestData::class])->get('/test', function () {
InitializeTenancyByRequestData::$cookie = 'X-Tenant';
InitializeTenancyByRequestData::$queryParameter = 'tenant';
Route::middleware(['tenant', InitializeTenancyByRequestData::class])->get('/test', function () {
return 'Tenant id: ' . tenant('id'); return 'Tenant id: ' . tenant('id');
}); });
}); });
test('header identification works', function () { test('header identification works', function (string|null $tenantModelColumn) {
$tenant = Tenant::create(); if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$this $table->string($tenantModelColumn)->unique();
->withoutExceptionHandling()
->withHeader('X-Tenant', $tenant->id)
->get('test')
->assertSee($tenant->id);
}); });
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
test('query parameter identification works', function () { config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
$tenant = Tenant::create();
$this $tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
->withoutExceptionHandling() $payload = $tenantModelColumn ? 'acme' : $tenant->id;
->get('test?tenant=' . $tenant->id)
->assertSee($tenant->id); // Default header name
$this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test')->assertSee($tenant->id);
// Custom header name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => 'X-Custom-Tenant']);
$this->withoutExceptionHandling()->withHeader('X-Custom-Tenant', $payload)->get('test')->assertSee($tenant->id);
// Setting the header to null disables header identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => null]);
expect(fn () => $this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
test('query parameter identification works', function (string|null $tenantModelColumn) {
if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
}); });
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
test('cookie identification works', function () { config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
$tenant = Tenant::create();
$this $tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
->withoutExceptionHandling() $payload = $tenantModelColumn ? 'acme' : $tenant->id;
->withUnencryptedCookie('X-Tenant', $tenant->id)
->get('test') // Default query parameter name
->assertSee($tenant->id); $this->withoutExceptionHandling()->get('test?tenant=' . $payload)->assertSee($tenant->id);
// Custom query parameter name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'custom_tenant']);
$this->withoutExceptionHandling()->get('test?custom_tenant=' . $payload)->assertSee($tenant->id);
// Setting the query parameter to null disables query parameter identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => null]);
expect(fn () => $this->withoutExceptionHandling()->get('test?tenant=' . $payload))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
test('cookie identification works', function (string|null $tenantModelColumn) {
if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
}); });
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
test('middleware throws exception when tenant data is not provided in the request', function () { config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
$payload = $tenantModelColumn ? 'acme' : $tenant->id;
// Default cookie name
$this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test')->assertSee($tenant->id);
// Custom cookie name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => 'custom_tenant_id']);
$this->withoutExceptionHandling()->withUnencryptedCookie('custom_tenant_id', $payload)->get('test')->assertSee($tenant->id);
// Setting the cookie to null disables cookie identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => null]);
expect(fn () => $this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
// todo@tests encrypted cookie
test('an exception is thrown when no tenant data is provided in the request', function () {
pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class); pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class);
$this->withoutExceptionHandling()->get('test'); $this->withoutExceptionHandling()->get('test');
}); });

View file

@ -43,6 +43,9 @@ use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser; use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser;
use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException; use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
config(['tenancy.bootstrappers' => [ config(['tenancy.bootstrappers' => [
@ -67,6 +70,9 @@ beforeEach(function () {
DeleteResourceInTenant::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
$syncedAttributes = [ $syncedAttributes = [
'global_id', 'global_id',
'name', 'name',
@ -105,6 +111,30 @@ beforeEach(function () {
afterEach(function () { afterEach(function () {
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
});
test('resources created with the same global id in different tenant dbs will be synced to a single central resource', function () {
$tenants = [Tenant::create(), Tenant::create(), Tenant::create()];
migrateUsersTableForTenants();
// Only a single central user is created since the same global_id is used for each tenant user
// Therefore all of these tenant users are synced to a single global user
tenancy()->runForMultiple($tenants, function () {
// Create a user with the same global_id in each tenant DB
TenantUser::create([
'global_id' => 'acme',
'name' => Str::random(),
'email' => 'john@localhost',
'password' => 'secret',
'role' => 'commenter',
]);
});
expect(CentralUser::all())->toHaveCount(1);
expect(CentralUser::first()->global_id)->toBe('acme');
}); });
test('SyncedResourceSaved event gets triggered when resource gets created or when its synced attributes get updated', function () { test('SyncedResourceSaved event gets triggered when resource gets created or when its synced attributes get updated', function () {
@ -1172,6 +1202,69 @@ test('resource creation works correctly when central resource provides defaults
expect($centralUser->foo)->toBe('bar'); expect($centralUser->foo)->toBe('bar');
}); });
test('global scopes on syncable models can break resource syncing', function () {
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
$centralUser = CentralUser::create([
'global_id' => 'foo',
'name' => 'foo',
'email' => 'foo@bar.com',
'password' => '*****',
'role' => 'admin', // not 'visible'
]);
// Create a tenant resource. The global id matches that of the central user created above,
// so the synced columns of the central record will be updated.
$tenant1->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant1 user',
'email' => 'tenant1@user.com',
'password' => 'tenant1_password',
'role' => 'user1',
]));
expect($centralUser->refresh()->name)->toBe('tenant1 user');
// While syncing a tenant resource with the same global id,
// the central resource will not be found due to this scope,
// leading to the syncing logic trying to create a new central resource with that same global id,
// triggering a unique constraint violation exception.
CentralUser::addGlobalScope(new VisibleScope());
expect(function () use ($tenant1) {
$tenant1->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant1new user',
'email' => 'tenant1new@user.com',
'password' => 'tenant1new_password',
'role' => 'user1new',
]));
})->toThrow(QueryException::class, "Duplicate entry 'foo' for key 'users.users_global_id_unique'");
// The central resource stays the same
expect($centralUser->refresh()->name)->toBe('tenant1 user');
// Use UpdateOrCreateSyncedResource::$scopeGetModelQuery to bypass the global scope.
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
$query->withoutGlobalScope(VisibleScope::class);
};
// Now, the central resource IS found, and no exception is thrown
$tenant2->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant2 user',
'email' => 'tenant2@user.com',
'password' => 'tenant2_password',
'role' => 'user2',
]));
// The central resource was updated
expect($centralUser->refresh()->name)->toBe('tenant2 user');
// The change was also synced to tenant1
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
});
/** /**
* Create two tenants and run migrations for those tenants. * Create two tenants and run migrations for those tenants.
* *
@ -1243,6 +1336,14 @@ class TenantUser extends BaseTenantUser
} }
} }
class VisibleScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('role', 'visible');
}
}
class TenantPivot extends BasePivot class TenantPivot extends BasePivot
{ {
public $table = 'tenant_users'; public $table = 'tenant_users';
@ -1320,6 +1421,7 @@ class CentralCompany extends Model implements SyncMaster
]; ];
} }
} }
class TenantCompany extends Model implements Syncable class TenantCompany extends Model implements Syncable
{ {
use ResourceSyncing; use ResourceSyncing;

View file

@ -69,6 +69,18 @@ test('tenancy detects presence of route middleware correctly', function (string
InitializeTenancyByDomainOrSubdomain::class, InitializeTenancyByDomainOrSubdomain::class,
]); ]);
test('getRouteMiddleware properly unpacks all mw groups on a route', function() {
$route = Route::get('/foo', fn () => true)->middleware(['foo', 'bar']);
Route::middlewareGroup('foo', [PreventAccessFromUnwantedDomains::class]);
Route::middlewareGroup('bar', [InitializeTenancyByDomain::class]);
expect(tenancy()->getRouteMiddleware($route))->toContain(
PreventAccessFromUnwantedDomains::class,
InitializeTenancyByDomain::class
);
});
test('domain identification middleware is configurable', function() { test('domain identification middleware is configurable', function() {
$route = Route::get('/welcome-route', fn () => 'welcome')->middleware([InitializeTenancyByDomain::class]); $route = Route::get('/welcome-route', fn () => 'welcome')->middleware([InitializeTenancyByDomain::class]);

Some files were not shown because too many files have changed in this diff Show more