mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 16:44:04 +00:00
Merge branch 'master' into database-cache-bootstrapper
This commit is contained in:
commit
2cfa8831a3
113 changed files with 3035 additions and 1373 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- laravel: "^11.0"
|
||||
- laravel: "^12.0"
|
||||
php: "8.4"
|
||||
|
||||
steps:
|
||||
|
|
|
|||
12
.github/workflows/queue.yml
vendored
12
.github/workflows/queue.yml
vendored
|
|
@ -10,12 +10,20 @@ jobs:
|
|||
steps:
|
||||
- name: Prepare composer version constraint prefix
|
||||
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/}
|
||||
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
|
||||
else
|
||||
# All other branches use dev-${branch} prefix
|
||||
echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Clone test suite
|
||||
run: git clone https://github.com/archtechx/tenancy-queue-tester
|
||||
|
|
@ -25,3 +33,5 @@ jobs:
|
|||
cd tenancy-queue-tester
|
||||
TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh
|
||||
./test.sh
|
||||
./alternative_config.sh
|
||||
PERSISTENT=1 ./test.sh
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ $finder = Finder::create()
|
|||
->in([
|
||||
$project_path . '/src',
|
||||
])
|
||||
->exclude('Enums')
|
||||
->name('*.php')
|
||||
->notName('*.blade.php')
|
||||
->ignoreDotFiles(true)
|
||||
|
|
|
|||
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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
|
||||
|
|
@ -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`.
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
To run tests and generate coverage reports, use `composer test-full`.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Events\DeletingTenant::class => [
|
||||
JobPipeline::make([
|
||||
Jobs\DeleteDomains::class,
|
||||
// Jobs\RemoveStorageSymlinks::class,
|
||||
])->send(function (Events\DeletingTenant $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false),
|
||||
|
|
@ -62,7 +63,6 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Events\TenantDeleted::class => [
|
||||
JobPipeline::make([
|
||||
Jobs\DeleteDatabase::class,
|
||||
// Jobs\RemoveStorageSymlinks::class,
|
||||
])->send(function (Events\TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->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
|
||||
{
|
||||
/**
|
||||
* Import your tenant model!
|
||||
*
|
||||
* \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) {
|
||||
* $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant
|
||||
* ? $tenant->domain
|
||||
* : $tenant->domains->first()->domain;
|
||||
*
|
||||
* $scheme = str($originalRootUrl)->before('://');
|
||||
*
|
||||
* // If you're using domain identification:
|
||||
* return $scheme . '://' . $tenantDomain . '/';
|
||||
*
|
||||
* // If you're using subdomain identification:
|
||||
* $originalDomain = str($originalRootUrl)->after($scheme . '://');
|
||||
* return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/';
|
||||
* };
|
||||
*/
|
||||
// \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) {
|
||||
// $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant
|
||||
// ? $tenant->domain
|
||||
// : $tenant->domains->first()->domain;
|
||||
//
|
||||
// $scheme = str($originalRootUrl)->before('://');
|
||||
//
|
||||
// if (str_contains($tenantDomain, '.')) {
|
||||
// // Domain identification
|
||||
// return $scheme . '://' . $tenantDomain . '/';
|
||||
// } else {
|
||||
// // Subdomain identification
|
||||
// $originalDomain = str($originalRootUrl)->after($scheme . '://')->before('/');
|
||||
// return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/';
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
public function register()
|
||||
|
|
@ -178,32 +176,17 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
$this->makeTenancyMiddlewareHighestPriority();
|
||||
$this->overrideUrlInTenantContext();
|
||||
|
||||
/**
|
||||
* Include soft deleted resources in synced resource queries.
|
||||
*
|
||||
* ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
|
||||
* 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;
|
||||
// // Include soft deleted resources in synced resource queries.
|
||||
// ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
|
||||
// if ($query->hasMacro('withTrashed')) {
|
||||
// $query->withTrashed();
|
||||
// }
|
||||
// };
|
||||
|
||||
if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) {
|
||||
TenancyUrlGenerator::$prefixRouteNames = true;
|
||||
}
|
||||
// // 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', \Stancl\Tenancy\Tenancy::defaultMiddleware()]);
|
||||
// });
|
||||
}
|
||||
|
||||
protected function bootEvents()
|
||||
|
|
@ -228,15 +211,14 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
->group(base_path('routes/tenant.php'));
|
||||
}
|
||||
|
||||
// Delete this condition when using route-level path identification
|
||||
if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) {
|
||||
$this->cloneRoutes();
|
||||
}
|
||||
// $this->cloneRoutes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone universal routes as tenant.
|
||||
* Clone routes as tenant.
|
||||
*
|
||||
* This is used primarily for integrating packages.
|
||||
*
|
||||
* @see CloneRoutesAsTenant
|
||||
*/
|
||||
|
|
@ -245,16 +227,23 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
/** @var CloneRoutesAsTenant $cloneRoutes */
|
||||
$cloneRoutes = $this->app->make(CloneRoutesAsTenant::class);
|
||||
|
||||
/**
|
||||
* You can provide a closure for cloning a specific route, e.g.:
|
||||
* $cloneRoutes->cloneUsing('welcome', function () {
|
||||
* RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey())
|
||||
* ->middleware(['universal', InitializeTenancyByPath::class])
|
||||
* ->name('tenant.welcome');
|
||||
* });
|
||||
*
|
||||
* To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant.
|
||||
*/
|
||||
// The cloning action has two modes:
|
||||
// 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property.
|
||||
// You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action.
|
||||
//
|
||||
// By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle()
|
||||
// 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.
|
||||
//
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ return [
|
|||
'models' => [
|
||||
'tenant' => Stancl\Tenancy\Database\Models\Tenant::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.
|
||||
|
|
@ -33,6 +34,7 @@ return [
|
|||
*
|
||||
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator
|
||||
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator
|
||||
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator
|
||||
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator
|
||||
*/
|
||||
'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class,
|
||||
|
|
@ -90,7 +92,7 @@ return [
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* If you're using a custom path identification middleware, add it here.
|
||||
|
|
@ -117,6 +119,7 @@ return [
|
|||
Resolvers\PathTenantResolver::class => [
|
||||
'tenant_parameter_name' => 'tenant',
|
||||
'tenant_model_column' => null, // null = tenant key
|
||||
'tenant_route_name_prefix' => 'tenant.',
|
||||
'allowed_extra_model_columns' => [], // used with binding route fields
|
||||
|
||||
'cache' => false,
|
||||
|
|
@ -124,13 +127,18 @@ return [
|
|||
'cache_store' => null, // null = default
|
||||
],
|
||||
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_ttl' => 3600, // seconds
|
||||
'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
|
||||
],
|
||||
|
||||
// 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,
|
||||
],
|
||||
|
||||
|
|
@ -319,7 +334,6 @@ return [
|
|||
*/
|
||||
'url_override' => [
|
||||
// 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%',
|
||||
],
|
||||
|
||||
|
|
@ -355,7 +369,7 @@ return [
|
|||
* 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).
|
||||
*/
|
||||
'asset_helper_tenancy' => false, // todo@rename asset_helper_override?
|
||||
'asset_helper_override' => false,
|
||||
],
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,26 +18,22 @@
|
|||
"require": {
|
||||
"php": "^8.4",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "^10.1|^11.3",
|
||||
"illuminate/support": "^12.0",
|
||||
"laravel/tinker": "^2.0",
|
||||
"facade/ignition-contracts": "^1.0.2",
|
||||
"spatie/ignition": "^1.4",
|
||||
"ramsey/uuid": "^4.7.3",
|
||||
"stancl/jobpipeline": "2.0.0-rc2",
|
||||
"stancl/virtualcolumn": "dev-master",
|
||||
"spatie/invade": "^1.1",
|
||||
"stancl/jobpipeline": "2.0.0-rc5",
|
||||
"stancl/virtualcolumn": "^1.5.0",
|
||||
"spatie/invade": "*",
|
||||
"laravel/prompts": "0.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^10.1|^11.3",
|
||||
"orchestra/testbench": "^8.0|^9.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"orchestra/testbench": "^10.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.12.2",
|
||||
"doctrine/dbal": "^3.6.0",
|
||||
"spatie/valuestore": "^1.2.5",
|
||||
"pestphp/pest": "^2.0",
|
||||
"larastan/larastan": "^3.0",
|
||||
"spatie/invade": "^1.1",
|
||||
"aws/aws-sdk-php-laravel": "~3.0"
|
||||
"pestphp/pest": "^3.0",
|
||||
"larastan/larastan": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
services:
|
||||
mysql:
|
||||
# platform: linux/amd64 # either one works
|
||||
image: arm64v8/mysql
|
||||
mysql2:
|
||||
# platform: linux/amd64 # either one works
|
||||
image: arm64v8/mysql
|
||||
mssql:
|
||||
image: mcr.microsoft.com/azure-sql-edge
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ services:
|
|||
stdin_open: true
|
||||
tty: true
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: main
|
||||
|
|
@ -46,7 +46,7 @@ services:
|
|||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
mysql2:
|
||||
image: mysql:5.7
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: main
|
||||
|
|
@ -72,12 +72,12 @@ services:
|
|||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=P@ssword # todo reuse env from above
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
includes:
|
||||
- ./vendor/larastan/larastan/extension.neon
|
||||
- ./vendor/spatie/invade/phpstan-extension.neon
|
||||
|
||||
parameters:
|
||||
paths:
|
||||
|
|
@ -16,6 +15,12 @@ parameters:
|
|||
ignoreErrors:
|
||||
- identifier: trait.unused
|
||||
- identifier: missingType.iterableValue
|
||||
-
|
||||
message: '#Spatie\\Invade\\Invader#'
|
||||
identifier: method.notFound
|
||||
-
|
||||
message: '#Spatie\\Invade\\Invader#'
|
||||
identifier: property.notFound
|
||||
- '#FFI#'
|
||||
- '#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#'
|
||||
|
|
|
|||
|
|
@ -7,40 +7,72 @@ namespace Stancl\Tenancy\Actions;
|
|||
use Closure;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
|
||||
/**
|
||||
* The CloneRoutesAsTenant action clones
|
||||
* routes flagged with the 'universal' middleware,
|
||||
* all routes without a flag if the default route mode is universal,
|
||||
* and routes that directly use the InitializeTenancyByPath middleware.
|
||||
* Clones either all existing routes for which shouldBeCloned() returns true
|
||||
* (by default, all routes with any middleware present in $cloneRoutesWithMiddleware),
|
||||
* or if any routes were manually added to $routesToClone using $action->cloneRoute($route),
|
||||
* 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
|
||||
* of packages (e.g., Jetstream or Livewire) easier with path-based tenant identification.
|
||||
* The main purpose of this action is to make the integration of packages
|
||||
* (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)
|
||||
* and prefixed with the '/{tenant}' path prefix. Their name also gets prefixed with the tenant name prefix.
|
||||
* The default for $cloneRoutesWithMiddleware is ['clone'].
|
||||
* 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.
|
||||
* Unlike universal routes, these routes don't get the tenant flag,
|
||||
* because they don't need it (they're not universal, and they have the identification MW, so they're already considered tenant).
|
||||
* You may customize $cloneRoutesWithMiddleware using cloneRoutesWithMiddleware() to make any middleware of your choice trigger cloning.
|
||||
* 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.
|
||||
*
|
||||
* You can use the `cloneUsing()` hook to customize the route definitions,
|
||||
* and the `skipRoute()` method to skip cloning of specific routes.
|
||||
* You can also use the $tenantParameterName and $tenantRouteNamePrefix
|
||||
* static properties to customize the tenant parameter name or the route name prefix.
|
||||
* Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'.
|
||||
* The parameter name and prefix can be changed e.g. to `/{team}` and `team.` by configuring the path resolver (tenantParameterName and tenantRouteNamePrefix).
|
||||
* Routes with names that are already prefixed won't be cloned - but that's just the default behavior.
|
||||
* 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
|
||||
{
|
||||
protected array $cloneRouteUsing = [];
|
||||
protected array $skippedRoutes = [
|
||||
'stancl.tenancy.asset',
|
||||
];
|
||||
protected array $routesToClone = [];
|
||||
protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
|
||||
protected Closure|null $shouldClone = null;
|
||||
protected array $cloneRoutesWithMiddleware = ['clone'];
|
||||
|
||||
public function __construct(
|
||||
protected Router $router,
|
||||
|
|
@ -48,100 +80,77 @@ class CloneRoutesAsTenant
|
|||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the action clone a specific route using the provided callback instead of the default one.
|
||||
*/
|
||||
public function cloneUsing(string $routeName, Closure $callback): static
|
||||
public function cloneUsing(Closure|null $cloneUsing): static
|
||||
{
|
||||
$this->cloneRouteUsing[$routeName] = $callback;
|
||||
$this->cloneUsing = $cloneUsing;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a route's cloning.
|
||||
*/
|
||||
public function skipRoute(string $routeName): static
|
||||
public function cloneRoutesWithMiddleware(array $middleware): static
|
||||
{
|
||||
$this->skippedRoutes[] = $routeName;
|
||||
$this->cloneRoutesWithMiddleware = $middleware;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Route>
|
||||
*/
|
||||
protected function getRoutesToClone(): Collection
|
||||
public function shouldClone(Closure|null $shouldClone): static
|
||||
{
|
||||
$tenantParameterName = PathTenantResolver::tenantParameterName();
|
||||
$this->shouldClone = $shouldClone;
|
||||
|
||||
/**
|
||||
* Clone all routes that:
|
||||
* - don't have the tenant parameter
|
||||
* - aren't in the $skippedRoutes array
|
||||
* - are using path identification (kernel or route-level).
|
||||
*
|
||||
* Non-universal cloned routes will only be available in the tenant context,
|
||||
* universal routes will be available in both contexts.
|
||||
*/
|
||||
return collect($this->router->getRoutes()->get())->filter(function (Route $route) use ($tenantParameterName) {
|
||||
if (
|
||||
tenancy()->routeHasMiddleware($route, 'tenant') ||
|
||||
in_array($route->getName(), $this->skippedRoutes, true) ||
|
||||
in_array($tenantParameterName, $route->parameterNames(), true)
|
||||
) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function cloneRoute(Route|string $route): static
|
||||
{
|
||||
$this->routesToClone[] = $route;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function shouldBeCloned(Route $route): bool
|
||||
{
|
||||
// Don't clone routes that already have tenant parameter or prefix
|
||||
if ($this->routeIsTenant($route)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pathIdentificationMiddleware = config('tenancy.identification.path_identification_middleware');
|
||||
$routeHasPathIdentificationMiddleware = tenancy()->routeHasMiddleware($route, $pathIdentificationMiddleware);
|
||||
$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'));
|
||||
});
|
||||
if ($this->shouldClone) {
|
||||
return ($this->shouldClone)($route);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
return tenancy()->routeHasMiddleware($route, $this->cloneRoutesWithMiddleware);
|
||||
}
|
||||
|
||||
protected function createNewRoute(Route $route): Route
|
||||
|
|
@ -150,33 +159,24 @@ class CloneRoutesAsTenant
|
|||
$prefix = trim($route->getPrefix() ?? '', '/');
|
||||
$uri = $route->getPrefix() ? Str::after($route->uri(), $prefix) : $route->uri();
|
||||
|
||||
$newRouteAction = collect($route->action)->tap(function (Collection $action) use ($route, $prefix) {
|
||||
/** @var array $routeMiddleware */
|
||||
$routeMiddleware = $action->get('middleware') ?? [];
|
||||
$action = collect($route->action);
|
||||
|
||||
// Make the new route have the same middleware as the original 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)
|
||||
$newRouteMiddleware = collect($routeMiddleware)
|
||||
->merge(['tenant']) // Add 'tenant' flag
|
||||
->filter(fn (string $middleware) => ! in_array($middleware, ['universal', 'clone']))
|
||||
->toArray();
|
||||
// Exclude $this->cloneRoutesWithMiddleware MW from the new route (it should only be flagged as tenant)
|
||||
|
||||
$tenantRouteNamePrefix = PathTenantResolver::tenantRouteNamePrefix();
|
||||
$middleware = $this->processMiddlewareForCloning($action->get('middleware') ?? []);
|
||||
|
||||
// Make sure the route name has the tenant route name prefix
|
||||
$newRouteNamePrefix = $route->getName()
|
||||
? $tenantRouteNamePrefix . Str::after($route->getName(), $tenantRouteNamePrefix)
|
||||
: null;
|
||||
if ($name = $route->getName()) {
|
||||
$action->put('as', PathTenantResolver::tenantRouteNamePrefix() . $name);
|
||||
}
|
||||
|
||||
return $action
|
||||
->put('as', $newRouteNamePrefix)
|
||||
->put('middleware', $newRouteMiddleware)
|
||||
$action
|
||||
->put('middleware', $middleware)
|
||||
->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}');
|
||||
})->toArray();
|
||||
|
||||
/** @var Route $newRoute */
|
||||
$newRoute = $this->router->$method($uri, $newRouteAction);
|
||||
$newRoute = $this->router->$method($uri, $action->toArray());
|
||||
|
||||
return $newRoute;
|
||||
}
|
||||
|
|
@ -194,4 +194,26 @@ class CloneRoutesAsTenant
|
|||
->withTrashed($originalRoute->allowsTrashedBindings())
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
|
|||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,54 +7,63 @@ namespace Stancl\Tenancy\Bootstrappers\Integrations;
|
|||
use Illuminate\Config\Repository;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Enums\Context;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
|
||||
/**
|
||||
* Allows customizing Fortify action redirects
|
||||
* so that they can also redirect to tenant routes instead of just the central routes.
|
||||
* Allows customizing Fortify action redirects so that they can also redirect
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* 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).
|
||||
* 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,
|
||||
* ],
|
||||
* ];
|
||||
* Syntax: ['redirect_name' => 'tenant_route_name']
|
||||
*/
|
||||
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).
|
||||
* 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 = [];
|
||||
|
||||
|
|
@ -76,27 +85,28 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper
|
|||
|
||||
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();
|
||||
$tenantParameterValue = PathTenantResolver::tenantParameterValue($tenant);
|
||||
}
|
||||
|
||||
$generateLink = function (array $redirect) use ($tenantKey, $tenantParameterName) {
|
||||
// Specifying the context is only required with query string identification
|
||||
// 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] : []);
|
||||
$generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) {
|
||||
return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []);
|
||||
};
|
||||
|
||||
// Get redirect URLs for the configured redirect routes
|
||||
$redirects = array_merge(
|
||||
$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) {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
146
src/Bootstrappers/PersistentQueueTenancyBootstrapper.php
Normal file
146
src/Bootstrappers/PersistentQueueTenancyBootstrapper.php
Normal 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 {}
|
||||
}
|
||||
|
|
@ -24,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
/** @var QueueManager */
|
||||
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.
|
||||
* 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);
|
||||
});
|
||||
|
||||
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls
|
||||
$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
|
||||
|
|
@ -79,61 +72,25 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revert back to the previous tenant
|
||||
if (tenant() && $previousTenant?->isNot(tenant())) {
|
||||
tenancy()->initialize($previousTenant);
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = tenancy()->find($tenantId);
|
||||
tenancy()->initialize($tenant);
|
||||
}
|
||||
|
||||
// End tenancy
|
||||
if (tenant() && (! $previousTenant)) {
|
||||
protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void
|
||||
{
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -149,16 +106,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
}
|
||||
}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function getPayload(string $connection): array
|
||||
{
|
||||
if (! tenancy()->initialized) {
|
||||
|
|
@ -169,10 +116,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
return [];
|
||||
}
|
||||
|
||||
$id = tenant()->getTenantKey();
|
||||
|
||||
return [
|
||||
'tenant_id' => $id,
|
||||
'tenant_id' => tenant()->getTenantKey(),
|
||||
];
|
||||
}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void {}
|
||||
public function revert(): void {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Bootstrappers;
|
|||
use Closure;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
|
||||
|
|
@ -36,28 +35,43 @@ class RootUrlBootstrapper implements TenancyBootstrapper
|
|||
|
||||
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(
|
||||
protected UrlGenerator $urlGenerator,
|
||||
protected Repository $config,
|
||||
protected Application $app,
|
||||
) {}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
if ($this->app->runningInConsole() && static::$rootUrlOverride) {
|
||||
$this->originalRootUrl = $this->urlGenerator->to('/');
|
||||
if (static::$rootUrlOverride === null) {
|
||||
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);
|
||||
|
||||
$this->urlGenerator->forceRootUrl($newRootUrl);
|
||||
$this->app['url']->forceRootUrl($newRootUrl);
|
||||
$this->config->set('app.url', $newRootUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
if ($this->originalRootUrl) {
|
||||
$this->urlGenerator->forceRootUrl($this->originalRootUrl);
|
||||
$this->app['url']->forceRootUrl($this->originalRootUrl);
|
||||
$this->config->set('app.url', $this->originalRootUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Illuminate\Support\Facades\URL;
|
|||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @see TenancyUrlGenerator
|
||||
* @see \Stancl\Tenancy\Resolvers\PathTenantResolver
|
||||
* @see PathTenantResolver
|
||||
*/
|
||||
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(
|
||||
protected Application $app,
|
||||
protected UrlGenerator $originalUrlGenerator,
|
||||
|
|
@ -32,12 +43,12 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
|
|||
{
|
||||
URL::clearResolvedInstances();
|
||||
|
||||
$this->useTenancyUrlGenerator();
|
||||
$this->useTenancyUrlGenerator($tenant);
|
||||
}
|
||||
|
||||
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()
|
||||
*/
|
||||
protected function useTenancyUrlGenerator(): void
|
||||
protected function useTenancyUrlGenerator(Tenant $tenant): void
|
||||
{
|
||||
$this->app->extend('url', function (UrlGenerator $urlGenerator, Application $app) {
|
||||
$newGenerator = new TenancyUrlGenerator(
|
||||
$app['router']->getRoutes(),
|
||||
$urlGenerator->getRequest(),
|
||||
$app['config']->get('app.asset_url'),
|
||||
$this->app['router']->getRoutes(),
|
||||
$this->originalUrlGenerator->getRequest(),
|
||||
$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 () {
|
||||
return $this->app['session'] ?? null;
|
||||
|
|
@ -64,7 +88,6 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
|
|||
return $this->app->make('config')->get('app.key');
|
||||
});
|
||||
|
||||
return $newGenerator;
|
||||
});
|
||||
$this->app->extend('url', fn () => $newGenerator);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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)
|
||||
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
if (static::$forceRls) {
|
||||
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DatabaseConfig instance for the RLS user,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ trait DealsWithRouteContexts
|
|||
|
||||
foreach ($middleware as $inner) {
|
||||
if (! $inner instanceof Closure && isset($middlewareGroups[$inner])) {
|
||||
$innerMiddleware = Arr::wrap($middlewareGroups[$inner]);
|
||||
$innerMiddleware = array_merge($innerMiddleware, Arr::wrap($middlewareGroups[$inner]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,6 @@ trait DealsWithTenantSymlinks
|
|||
/** Determine if the provided path is an existing symlink. */
|
||||
protected function symlinkExists(string $link): bool
|
||||
{
|
||||
return file_exists($link) && is_link($link);
|
||||
return is_link($link);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,49 +5,11 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Contracts;
|
||||
|
||||
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 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
|
||||
protected function tenantCouldNotBeIdentified(string $how): void
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@ interface UniqueIdentifierGenerator
|
|||
/**
|
||||
* Generate a unique identifier for a model.
|
||||
*/
|
||||
public static function generate(Model $model): string;
|
||||
public static function generate(Model $model): string|int;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,11 @@ trait CreatesDatabaseUsers
|
|||
{
|
||||
public function createDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
parent::createDatabase($tenant);
|
||||
|
||||
return $this->createUser($tenant->database());
|
||||
return parent::createDatabase($tenant) && $this->createUser($tenant->database());
|
||||
}
|
||||
|
||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
// Some DB engines require the user to be deleted before the database (e.g. Postgres)
|
||||
$this->deleteUser($tenant->database());
|
||||
|
||||
return parent::deleteDatabase($tenant);
|
||||
return $this->deleteUser($tenant->database()) && parent::deleteDatabase($tenant);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ use Stancl\Tenancy\Events\PullingPendingTenant;
|
|||
*/
|
||||
trait HasPending
|
||||
{
|
||||
public static string $pendingSinceCast = 'timestamp';
|
||||
|
||||
/** Boot the trait. */
|
||||
public static function bootHasPending(): void
|
||||
{
|
||||
|
|
@ -32,7 +34,7 @@ trait HasPending
|
|||
/** Initialize the trait. */
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -4,16 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
trait InvalidatesResolverCache
|
||||
{
|
||||
public static function bootInvalidatesResolverCache(): void
|
||||
{
|
||||
static::saved(function (Tenant&Model $tenant) {
|
||||
Tenancy::invalidateResolverCache($tenant);
|
||||
});
|
||||
static::saved(Tenancy::invalidateResolverCache(...));
|
||||
static::deleting(Tenancy::invalidateResolverCache(...));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
/**
|
||||
|
|
@ -15,13 +14,9 @@ trait InvalidatesTenantsResolverCache
|
|||
{
|
||||
public static function bootInvalidatesTenantsResolverCache(): void
|
||||
{
|
||||
static::saved(function (Model $model) {
|
||||
foreach (Tenancy::cachedResolvers() as $resolver) {
|
||||
/** @var CachedTenantResolver $resolver */
|
||||
$resolver = app($resolver);
|
||||
$invalidateCache = static fn (Model $model) => Tenancy::invalidateResolverCache($model->tenant);
|
||||
|
||||
$resolver->invalidateCache($model->tenant);
|
||||
}
|
||||
});
|
||||
static::saved($invalidateCache);
|
||||
static::deleting($invalidateCache);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class PendingScope implements Scope
|
|||
/**
|
||||
* Apply the scope to a given Eloquent query builder.
|
||||
*
|
||||
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
|
||||
* @param Builder<Model> $builder
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class ImpersonationToken extends Model
|
|||
{
|
||||
use CentralConnection;
|
||||
|
||||
/** You can set this property to customize the table name */
|
||||
public static string $tableName = 'tenant_user_impersonation_tokens';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $timestamps = false;
|
||||
|
|
@ -33,11 +36,15 @@ class ImpersonationToken extends Model
|
|||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $table = 'tenant_user_impersonation_tokens';
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return static::$tableName;
|
||||
}
|
||||
|
||||
public static function booted(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager
|
|||
public function createDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
$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}]");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
|
|||
// Grant permissions to any existing tables. This is used with RLS
|
||||
// todo@samuel refactor this along with the todo in TenantDatabaseManager
|
||||
// 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) {
|
||||
$tableName = $table->table_name;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
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;
|
||||
case UNIVERSAL;
|
||||
case CENTRAL = 0b01;
|
||||
case TENANT = 0b10;
|
||||
case UNIVERSAL = 0b11;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,11 @@ namespace Stancl\Tenancy\Events\Contracts;
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
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;
|
||||
|
||||
/** @var Tenant */
|
||||
public $tenant;
|
||||
|
||||
public function __construct(Tenant $tenant)
|
||||
{
|
||||
$this->tenant = $tenant;
|
||||
}
|
||||
public function __construct(
|
||||
public Tenant $tenant,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedExce
|
|||
{
|
||||
public function __construct(int|string $tenant_id)
|
||||
{
|
||||
$this
|
||||
->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.');
|
||||
$this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
// todo perhaps create Identification namespace
|
||||
|
||||
namespace Stancl\Tenancy\Exceptions;
|
||||
|
||||
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||
|
|
@ -12,9 +10,6 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified
|
|||
{
|
||||
public function __construct(int|string $tenant_id)
|
||||
{
|
||||
$this
|
||||
->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?');
|
||||
$this->tenantCouldNotBeIdentified("by tenant key: $tenant_id");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi
|
|||
{
|
||||
public function __construct(int|string $tenant_id)
|
||||
{
|
||||
$this
|
||||
->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?');
|
||||
$this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByRequestDataException extends TenantCouldNotBeI
|
|||
{
|
||||
public function __construct(mixed $payload)
|
||||
{
|
||||
$this
|
||||
->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?');
|
||||
$this->tenantCouldNotBeIdentified("by request data with payload: $payload");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedOnDomainException extends TenantCouldNotBeIdenti
|
|||
{
|
||||
public function __construct(string $domain)
|
||||
{
|
||||
$this
|
||||
->tenantCouldNotBeIdentified("on domain $domain")
|
||||
->title('Tenant could not be identified on this domain')
|
||||
->description('Did you forget to create a tenant for this domain?');
|
||||
$this->tenantCouldNotBeIdentified("on domain $domain");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Features;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Stancl\Tenancy\Contracts\Feature;
|
||||
|
|
@ -18,8 +19,8 @@ class UserImpersonation implements Feature
|
|||
|
||||
public function bootstrap(Tenancy $tenancy): void
|
||||
{
|
||||
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): ImpersonationToken {
|
||||
return ImpersonationToken::create([
|
||||
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
|
||||
return UserImpersonation::modelClass()::create([
|
||||
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
|
||||
'user_id' => $userId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
|
|
@ -30,10 +31,15 @@ class UserImpersonation implements Feature
|
|||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
|
||||
|
|
@ -54,6 +60,12 @@ class UserImpersonation implements Feature
|
|||
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
|
||||
{
|
||||
return session()->has('tenancy_impersonating');
|
||||
|
|
@ -62,7 +74,7 @@ class UserImpersonation implements Feature
|
|||
/**
|
||||
* 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use Illuminate\Routing\Events\RouteMatched;
|
|||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
|
||||
// todo@earlyIdReview
|
||||
|
||||
/**
|
||||
* Remove the tenant parameter from the matched route when path identification is used globally.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma
|
|||
{
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
|
|||
{
|
||||
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 bool $requireCookieEncryption = false;
|
||||
|
|
@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
|
|||
|
||||
protected function getPayload(Request $request): string|null
|
||||
{
|
||||
if (static::$header && $request->hasHeader(static::$header)) {
|
||||
$payload = $request->header(static::$header);
|
||||
} elseif (
|
||||
static::$queryParameter &&
|
||||
$request->has(static::$queryParameter)
|
||||
) {
|
||||
$payload = $request->get(static::$queryParameter);
|
||||
} elseif (static::$cookie && $request->hasCookie(static::$cookie)) {
|
||||
$payload = $request->cookie(static::$cookie);
|
||||
$headerName = RequestDataTenantResolver::headerName();
|
||||
$queryParameterName = RequestDataTenantResolver::queryParameterName();
|
||||
$cookieName = RequestDataTenantResolver::cookieName();
|
||||
|
||||
if ($headerName && $request->hasHeader($headerName)) {
|
||||
$payload = $request->header($headerName);
|
||||
} elseif ($queryParameterName && $request->has($queryParameterName)) {
|
||||
$payload = $request->get($queryParameterName);
|
||||
} elseif ($cookieName && $request->hasCookie($cookieName)) {
|
||||
$payload = $request->cookie($cookieName);
|
||||
|
||||
if ($payload && is_string($payload)) {
|
||||
$payload = $this->getTenantFromCookie($payload);
|
||||
$payload = $this->getTenantFromCookie($cookieName, $payload);
|
||||
}
|
||||
} else {
|
||||
$payload = null;
|
||||
|
|
@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
|
|||
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 (str_starts_with($cookie, 'eyJpdiI')) {
|
||||
if (str_starts_with($cookieValue, 'eyJpdiI')) {
|
||||
try {
|
||||
$json = base64_decode($cookie);
|
||||
$json = base64_decode($cookieValue);
|
||||
$data = json_decode($json, true);
|
||||
|
||||
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
|
||||
// return null and the cookie payload would get skipped.
|
||||
$cookie = CookieValuePrefix::validate(
|
||||
static::$cookie,
|
||||
Crypt::decryptString($cookie),
|
||||
$cookieValue = CookieValuePrefix::validate(
|
||||
$cookieName,
|
||||
Crypt::decryptString($cookieValue),
|
||||
Crypt::getAllKeys()
|
||||
);
|
||||
}
|
||||
|
|
@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
|
|||
return null;
|
||||
}
|
||||
|
||||
return $cookie;
|
||||
return $cookieValue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class PreventAccessFromUnwantedDomains
|
|||
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
|
||||
{
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ class ScopeSessions
|
|||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides;
|
|||
|
||||
use Illuminate\Cache\CacheManager as BaseCacheManager;
|
||||
|
||||
// todo@move move to Cache namespace?
|
||||
|
||||
class CacheManager extends BaseCacheManager
|
||||
{
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,43 +9,100 @@ use Illuminate\Routing\UrlGenerator;
|
|||
use Illuminate\Support\Arr;
|
||||
use InvalidArgumentException;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
|
||||
/**
|
||||
* This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled.
|
||||
*
|
||||
* TenancyUrlGenerator does two extra things:
|
||||
* 1. Autofill the {tenant} parameter in the tenant context with the current tenant if $passTenantParameterToRoutes is enabled (enabled by default)
|
||||
* 2. Prepend the route name with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled (disabled by default)
|
||||
* TenancyUrlGenerator does a few extra things:
|
||||
* - Autofills the tenant parameter in the tenant context with the current tenant.
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* 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)
|
||||
* route('tenant', [$bypassParameter => true]) => app.test/tenant.
|
||||
* For example, in tenant context:
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Determine if the route names passed to `route()` or `temporarySignedRoute()`
|
||||
* should get prefixed with the tenant route name prefix.
|
||||
* Should route names passed to route() or temporarySignedRoute()
|
||||
* get prefixed with the tenant route name prefix.
|
||||
*
|
||||
* This is useful when using path identification with packages that generate URLs,
|
||||
* like Jetstream, so that you don't have to manually prefix route names passed to each route() call.
|
||||
* This is useful when using e.g. path identification with third-party packages
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Determine if the tenant parameter should get passed
|
||||
* to the links generated by `route()` or `temporarySignedRoute()` whenever available
|
||||
* (enabled by default – works with both path and query string identification).
|
||||
* Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
|
||||
*
|
||||
* 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
|
||||
|
|
@ -99,7 +156,7 @@ class TenancyUrlGenerator extends UrlGenerator
|
|||
protected function prepareRouteInputs(string $name, array $parameters): array
|
||||
{
|
||||
if (! $this->routeBehaviorModificationBypassed($parameters)) {
|
||||
$name = $this->prefixRouteName($name);
|
||||
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
|
||||
$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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/RLS/Exceptions/RLSCommentConstraintException.php
Normal file
15
src/RLS/Exceptions/RLSCommentConstraintException.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,90 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\RLS\PolicyManagers;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Str;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* 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 function __construct(
|
||||
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 = [];
|
||||
|
||||
foreach ($trees ?: $this->shortestPaths() as $table => $path) {
|
||||
foreach ($paths ?: $this->shortestPaths() as $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:
|
||||
*
|
||||
* 'posts' => [
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'localColumn' => 'tenant_id',
|
||||
* 'foreignTable' => 'tenants',
|
||||
* 'foreignId' => 'id'
|
||||
* 'foreignColumn' => 'id'
|
||||
* ],
|
||||
* ],
|
||||
* 'comments' => [
|
||||
* [
|
||||
* 'foreignKey' => 'post_id',
|
||||
* 'localColumn' => 'post_id',
|
||||
* 'foreignTable' => 'posts',
|
||||
* 'foreignId' => 'id'
|
||||
* 'foreignColumn' => 'id'
|
||||
* ],
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'localColumn' => 'tenant_id',
|
||||
* '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) {
|
||||
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree);
|
||||
foreach ($this->getTableNames() as $tableName) {
|
||||
// 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
|
||||
* for the foreign keys of all tables – only the paths that lead to the tenants table are included.
|
||||
* Create a path array with the given parameters.
|
||||
* 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 = [];
|
||||
$builder = $this->database->getSchemaBuilder();
|
||||
|
||||
// We loop through each table in the database
|
||||
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;
|
||||
return [
|
||||
'dead_end' => $deadEnd,
|
||||
'recursive_relationship' => $recursive,
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* and the foreign key column comment. These additional details are removed
|
||||
* from the foreign keys/path steps before returning the final shortest paths.
|
||||
* Also provides internal metadata about
|
||||
* - the constraint's nullability (the 'nullable' key),
|
||||
* - the constraint's comment
|
||||
*
|
||||
* The 'comment' key gets deleted while generating the full trees (in generateTrees()),
|
||||
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
|
||||
* These internal details are then omitted
|
||||
* from the constraints (or the "path steps")
|
||||
* before returning the shortest paths in shortestPath().
|
||||
*
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'localColumn' => 'tenant_id',
|
||||
* 'foreignTable' => 'tenants',
|
||||
* 'foreignId' => 'id',
|
||||
* 'comment' => 'no-rls', // Foreign key comment – used to explicitly enable/disable RLS
|
||||
* 'nullable' => false, // Whether the foreign key is nullable
|
||||
* 'foreignColumn' => 'id',
|
||||
* 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata)
|
||||
* '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 [
|
||||
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0],
|
||||
'foreignTable' => $foreignKey['foreign_table'],
|
||||
'foreignId' => $foreignKey['foreign_columns'][0],
|
||||
// Deleted in generateTrees()
|
||||
'comment' => $this->getComment($table, $foreignKeyName),
|
||||
// Deleted in shortestPaths()
|
||||
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
|
||||
'localColumn' => $localColumn,
|
||||
'foreignTable' => $foreignTable,
|
||||
'foreignColumn' => $foreignColumn,
|
||||
// Internal metadata omitted in shortestPaths()
|
||||
'comment' => $comment,
|
||||
'nullable' => $nullable,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
protected function generateQuery(string $table, array $path): string
|
||||
{
|
||||
|
|
@ -214,9 +513,9 @@ class TableRLSManager implements RLSPolicyManager
|
|||
$sessionTenantKey = config('tenancy.rls.session_variable_name');
|
||||
|
||||
foreach ($path as $index => $relation) {
|
||||
$column = $relation['foreignKey'];
|
||||
$column = $relation['localColumn'];
|
||||
$table = $relation['foreignTable'];
|
||||
$foreignKey = $relation['foreignId'];
|
||||
$foreignKey = $relation['foreignColumn'];
|
||||
|
||||
$indentation = str_repeat(' ', ($index + 1) * 4);
|
||||
|
||||
|
|
@ -249,12 +548,65 @@ class TableRLSManager implements RLSPolicyManager
|
|||
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))
|
||||
->filter(fn ($column) => $column['name'] === $columnName)
|
||||
->first();
|
||||
$builder = $this->database->getSchemaBuilder();
|
||||
$tables = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
|
|||
|
||||
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
|
||||
|
|
@ -81,6 +81,11 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
|
|||
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[] */
|
||||
public static function allowedExtraModelColumns(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
|
|||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +31,43 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
|
|||
|
||||
public function getPossibleCacheKeys(Tenant&Model $tenant): array
|
||||
{
|
||||
// todo@tests
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -77,10 +77,12 @@ class Tenancy
|
|||
public function run(Tenant $tenant, Closure $callback): mixed
|
||||
{
|
||||
$originalTenant = $this->tenant;
|
||||
$result = null;
|
||||
|
||||
try {
|
||||
$this->initialize($tenant);
|
||||
$result = $callback($tenant);
|
||||
|
||||
} finally {
|
||||
if ($result instanceof PendingDispatch) { // #1277
|
||||
$result = null;
|
||||
}
|
||||
|
|
@ -90,6 +92,7 @@ class Tenancy
|
|||
} else {
|
||||
$this->end();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
|
@ -204,8 +207,10 @@ class Tenancy
|
|||
// Wrap string in array
|
||||
$tenants = is_string($tenants) ? [$tenants] : $tenants;
|
||||
|
||||
// Use all tenants if $tenants is falsy
|
||||
$tenants = $tenants ?: $this->model()->cursor(); // todo@phpstan phpstan thinks this isn't needed, but tests fail without it
|
||||
// If $tenants is falsy by this point (e.g. an empty array) there's no work to be done
|
||||
if (! $tenants) {
|
||||
return;
|
||||
}
|
||||
|
||||
$originalTenant = $this->tenant;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Cache\CacheManager;
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
use Illuminate\Routing\Events\RouteMatched;
|
||||
|
|
@ -18,9 +19,15 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
|||
|
||||
class TenancyServiceProvider extends ServiceProvider
|
||||
{
|
||||
public static Closure|null $configure = null;
|
||||
|
||||
/* Register services. */
|
||||
public function register(): void
|
||||
{
|
||||
if (static::$configure) {
|
||||
(static::$configure)();
|
||||
}
|
||||
|
||||
$this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy');
|
||||
|
||||
$this->app->singleton(Database\DatabaseManager::class);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class RandomHexGenerator implements UniqueIdentifierGenerator
|
|||
{
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
22
src/UniqueIdentifierGenerators/RandomIntGenerator.php
Normal file
22
src/UniqueIdentifierGenerators/RandomIntGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ class RandomStringGenerator implements UniqueIdentifierGenerator
|
|||
{
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
20
src/UniqueIdentifierGenerators/ULIDGenerator.php
Normal file
20
src/UniqueIdentifierGenerators/ULIDGenerator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
|
|||
*/
|
||||
class UUIDGenerator implements UniqueIdentifierGenerator
|
||||
{
|
||||
public static function generate(Model $model): string
|
||||
public static function generate(Model $model): string|int
|
||||
{
|
||||
return Uuid::uuid4()->toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ if (! function_exists('tenant')) {
|
|||
return app(Tenant::class);
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line nullsafe.neverNull
|
||||
return app(Tenant::class)?->getAttribute($key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
t
8
t
|
|
@ -1,3 +1,9 @@
|
|||
#!/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
8
test
|
|
@ -1,4 +1,10 @@
|
|||
#!/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
|
||||
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} "$@"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|||
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
|
||||
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
@ -35,11 +36,15 @@ test('create storage symlinks action works', function() {
|
|||
|
||||
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);
|
||||
|
||||
$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));
|
||||
});
|
||||
|
||||
|
|
@ -61,9 +66,48 @@ test('remove storage symlinks action works', function() {
|
|||
|
||||
(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);
|
||||
|
||||
$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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
|
|||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
|||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->mockConsoleOutput = false;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
|
|||
use Stancl\Tenancy\Listeners;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
/**
|
||||
* This collection of regression tests verifies that SessionTenancyBootstrapper
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ use Stancl\Tenancy\Events\TenancyEnded;
|
|||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
|
|||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::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() {
|
||||
|
|
@ -25,53 +33,31 @@ test('fortify route tenancy bootstrapper updates fortify config correctly', func
|
|||
return true;
|
||||
})->name($homeRouteName = 'home');
|
||||
|
||||
Route::get('/{tenant}/home', function () {
|
||||
return true;
|
||||
})->name($pathIdHomeRouteName = 'tenant.home');
|
||||
|
||||
Route::get('/welcome', function () {
|
||||
return true;
|
||||
})->name($welcomeRouteName = 'welcome');
|
||||
|
||||
Route::get('/{tenant}/welcome', function () {
|
||||
return true;
|
||||
})->name($pathIdWelcomeRouteName = 'path.welcome');
|
||||
|
||||
FortifyRouteBootstrapper::$fortifyHome = $homeRouteName;
|
||||
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = $welcomeRouteName;
|
||||
|
||||
// Make login redirect to the central welcome route
|
||||
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [
|
||||
'route_name' => $welcomeRouteName,
|
||||
'context' => Context::CENTRAL,
|
||||
];
|
||||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
|
||||
FortifyRouteBootstrapper::$passTenantParameter = true;
|
||||
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());
|
||||
|
||||
// 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']);
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
|
||||
|
||||
tenancy()->end();
|
||||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
|
||||
// Making a route's context will pass the tenant parameter to the route
|
||||
FortifyRouteBootstrapper::$fortifyRedirectMap['login']['context'] = Context::TENANT;
|
||||
|
||||
FortifyRouteBootstrapper::$passTenantParameter = false;
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
|
||||
|
||||
// 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;
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home');
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(config('fortify.home'))->toBe("http://localhost/{$tenant->getTenantKey()}/home");
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => "http://localhost/{$tenant->getTenantKey()}/welcome"]);
|
||||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,18 +10,23 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
|
||||
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
RootUrlBootstrapper::$rootUrlOverride = null;
|
||||
RootUrlBootstrapper::$rootUrlOverrideInTests = true;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
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]]);
|
||||
|
||||
Route::group([
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
|
@ -12,19 +13,26 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Illuminate\Routing\Exceptions\UrlGenerationException;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
TenancyUrlGenerator::$prefixRouteNames = false;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
|
||||
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
TenancyUrlGenerator::$prefixRouteNames = false;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
|
||||
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
|
||||
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('url generator bootstrapper can prefix route names passed to the route helper', function() {
|
||||
Route::get('/central/home', fn () => route('home'))->name('home');
|
||||
// Tenant route name prefix is 'tenant.' by default
|
||||
Route::get('/{tenant}/home', fn () => route('tenant.home'))->name('tenant.home')->middleware(['tenant', InitializeTenancyByPath::class]);
|
||||
test('tenancy url generator can prefix route names passed to the route helper', function() {
|
||||
config([
|
||||
'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'custom_prefix.',
|
||||
]);
|
||||
|
||||
Route::get('/central/home', fn () => '')->name('home');
|
||||
Route::get('/tenant/home', fn () => '')->name('custom_prefix.home');
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenantKey = $tenant->getTenantKey();
|
||||
$centralRouteUrl = route('home');
|
||||
$tenantRouteUrl = route('tenant.home', ['tenant' => $tenantKey]);
|
||||
TenancyUrlGenerator::$bypassParameter = 'bypassParameter';
|
||||
|
||||
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false
|
||||
expect(route('home'))->not()->toBe($centralRouteUrl);
|
||||
// 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);
|
||||
|
||||
// Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false (default)
|
||||
expect(route('home'))->toBe('http://localhost/central/home');
|
||||
|
||||
// When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed automatically.
|
||||
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.'
|
||||
// Also, the route receives the tenant parameter automatically
|
||||
expect(route('tenant.home'))->toBe($tenantRouteUrl);
|
||||
expect(route('home'))->toBe('http://localhost/tenant/home');
|
||||
|
||||
// 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
|
||||
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 () {
|
||||
|
|
@ -105,54 +317,8 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b
|
|||
->not()->toContain('bypassParameter');
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
|
|||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
||||
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
withTenantDatabases();
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
|||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\PathIdentificationManager;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
test('tenants can be resolved using cached resolvers', function (string $resolver) {
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
|
|
@ -84,6 +86,34 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
|
|||
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 () {
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
$tenant->createDomain($tenantKey);
|
||||
|
|
@ -110,6 +140,26 @@ test('cache is invalidated when a tenants domain is changed', function () {
|
|||
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() {
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
|
||||
DB::enableQueryLog();
|
||||
|
|
|
|||
|
|
@ -1,269 +1,195 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Routing\Route;
|
||||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Stancl\Tenancy\Tests\Etc\HasMiddlewareController;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
test('a route can be universal using path identification', function (array $routeMiddleware, array $globalMiddleware) {
|
||||
foreach ($globalMiddleware as $middleware) {
|
||||
if ($middleware === 'universal') {
|
||||
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]);
|
||||
} else {
|
||||
app(Kernel::class)->pushMiddleware($middleware);
|
||||
}
|
||||
}
|
||||
test('CloneRoutesAsTenant action clones routes with clone middleware by default', function () {
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
|
||||
|
||||
RouteFacade::get('/foo', function () {
|
||||
return tenancy()->initialized
|
||||
? 'Tenancy is initialized.'
|
||||
: 'Tenancy is not initialized.';
|
||||
})->middleware($routeMiddleware);
|
||||
// Should not be cloned
|
||||
RouteFacade::get('/central', fn () => true)->name('central');
|
||||
|
||||
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 */
|
||||
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
|
||||
|
||||
$cloneRoutesAction->handle();
|
||||
|
||||
$tenantKey = Tenant::create()->getTenantKey();
|
||||
$newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
|
||||
|
||||
pest()->get("http://localhost/foo")
|
||||
->assertSuccessful()
|
||||
->assertSee('Tenancy is not initialized.');
|
||||
expect($newRoutes->count())->toEqual(1);
|
||||
|
||||
pest()->get("http://localhost/{$tenantKey}/foo")
|
||||
->assertSuccessful()
|
||||
->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']))
|
||||
);
|
||||
$newRoute = $newRoutes->first();
|
||||
expect($newRoute->uri())->toBe('{team}/foo');
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
||||
pest()->get(route($centralRouteName = $universalRoute->getName()))->assertSee('Tenancy is not initialized.');
|
||||
pest()->get(route($centralRouteName2 = $universalRoute2->getName()))->assertSee('Tenancy is not initialized.');
|
||||
pest()->get(route($tenantRouteName = $newRoutes->first()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.');
|
||||
tenancy()->end();
|
||||
pest()->get(route($tenantRouteName2 = $newRoutes->last()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.');
|
||||
expect($newRoute->getName())->toBe('team-route.foo');
|
||||
pest()->get(route('team-route.foo', ['team' => $tenant->id]))->assertOk();
|
||||
expect(tenancy()->getRouteMiddleware($newRoute))
|
||||
->toContain('tenant')
|
||||
->not()->toContain('clone');
|
||||
});
|
||||
|
||||
expect($tenantRouteName)->toBe($tenantRouteNamePrefix . $universalRoute->getName());
|
||||
expect($tenantRouteName2)->toBe($tenantRouteNamePrefix . $universalRoute2->getName());
|
||||
expect($centralRouteName)->toBe($universalRoute->getName());
|
||||
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 action clones only specified routes when using cloneRoute()', function () {
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
|
||||
|
||||
test('CloneRoutesAsTenant only clones routes with path identification by default', function () {
|
||||
app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class);
|
||||
// Should not be cloned
|
||||
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
|
||||
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);
|
||||
$originalRoutes = RouteFacade::getRoutes()->get();
|
||||
|
||||
/** @var CloneRoutesAsTenant $cloneRoutesAction */
|
||||
$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();
|
||||
|
||||
// Only one of the two routes gets cloned
|
||||
expect($currentRouteCount())->toBe($newRouteCount + 1);
|
||||
$newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
|
||||
|
||||
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 () {
|
||||
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware(['universal', InitializeTenancyByPath::class])->name($routeName = 'home');
|
||||
test('all routes with any of the middleware specified in cloneRoutesWithMiddleware will be cloned by default', function (array $cloneRoutesWithMiddleware) {
|
||||
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 */
|
||||
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
|
||||
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
|
||||
$initialRouteCount = $currentRouteCount();
|
||||
|
||||
$cloneRoutesAction;
|
||||
// No routes should be cloned
|
||||
$cloneRoutesAction
|
||||
->shouldClone(fn (Route $route) => false)
|
||||
->handle();
|
||||
|
||||
// Skip cloning the 'home' route
|
||||
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) {
|
||||
return;
|
||||
})->handle();
|
||||
|
||||
// Expect route count to stay the same because the 'home' route cloning gets skipped
|
||||
// Expect route count to stay the same because cloning essentially gets turned off
|
||||
expect($initialRouteCount)->toEqual($currentRouteCount());
|
||||
|
||||
// Modify the 'home' route cloning so that a different route is cloned
|
||||
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) {
|
||||
RouteFacade::get('/cloned-route', fn () => true)->name('new.home');
|
||||
})->handle();
|
||||
// Only the 'home' route should be cloned
|
||||
$cloneRoutesAction
|
||||
->shouldClone(fn (Route $route) => $route->getName() === 'home')
|
||||
->handle();
|
||||
|
||||
expect($currentRouteCount())->toEqual($initialRouteCount + 1);
|
||||
});
|
||||
|
||||
test('cloning of specific routes can get skipped', function () {
|
||||
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware('universal')->name($routeName = 'home');
|
||||
test('custom callbacks can be used for customizing the creation of the cloned routes', function () {
|
||||
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
|
||||
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
|
||||
|
||||
/** @var CloneRoutesAsTenant $cloneRoutesAction */
|
||||
$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());
|
||||
$initialRouteCount = $currentRouteCount();
|
||||
|
||||
// Skip cloning the 'home' route
|
||||
$cloneRoutesAction->skipRoute($routeName);
|
||||
/** @var CloneRoutesAsTenant $cloneRoutesAction */
|
||||
$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
|
||||
expect($initialRouteCount)->toEqual($currentRouteCount());
|
||||
});
|
||||
// Exactly one route should be cloned
|
||||
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) {
|
||||
foreach ($globalMiddleware as $middleware) {
|
||||
if ($middleware === 'universal') {
|
||||
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]);
|
||||
} 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]);
|
||||
expect(RouteFacade::getRoutes()->getByName('tenant.bar'))->not()->toBeNull();
|
||||
})->with([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
|
||||
test('the clone action prefixes already prefixed routes correctly', function () {
|
||||
$routes = [
|
||||
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/home', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('home')
|
||||
->prefix('prefix'),
|
||||
|
||||
RouteFacade::get('/leadingAndTrailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/leadingAndTrailingSlash', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('leadingAndTrailingSlash')
|
||||
->prefix('/prefix/'),
|
||||
|
||||
RouteFacade::get('/leadingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/leadingSlash', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('leadingSlash')
|
||||
->prefix('/prefix'),
|
||||
|
||||
RouteFacade::get('/trailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/trailingSlash', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('trailingSlash')
|
||||
->prefix('prefix/'),
|
||||
];
|
||||
|
|
@ -285,14 +211,14 @@ test('the clone action prefixes already prefixed routes correctly', function ()
|
|||
|
||||
expect($clonedRouteUrl)
|
||||
// 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//")
|
||||
// Route is prefixed correctly
|
||||
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}/{$routes[$key]->getName()}");
|
||||
// Instead, the route is prefixed correctly
|
||||
->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}");
|
||||
|
||||
// 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('')->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
|
||||
RouteFacade::get('/', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('landing');
|
||||
|
||||
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
|
||||
->middleware(['universal', InitializeTenancyByPath::class])
|
||||
RouteFacade::get('/home', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->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()]);
|
||||
$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)
|
||||
->not()->toContain("prefix//")
|
||||
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}");
|
||||
->toBe("http://localhost/prefix/{$tenant->id}");
|
||||
|
||||
expect($clonedHomeRouteUrl)
|
||||
->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', [
|
||||
'kernel identification' => [
|
||||
['universal'], // Route middleware
|
||||
[InitializeTenancyByPath::class], // Global Global middleware
|
||||
],
|
||||
'route-level identification' => [
|
||||
['universal', InitializeTenancyByPath::class], // Route middleware
|
||||
[], // Global middleware
|
||||
],
|
||||
'kernel identification + defaulting to universal routes' => [
|
||||
[], // Route middleware
|
||||
['universal', InitializeTenancyByPath::class], // Global middleware
|
||||
],
|
||||
'route-level identification + defaulting to universal routes' => [
|
||||
[InitializeTenancyByPath::class], // Route middleware
|
||||
['universal'], // Global middleware
|
||||
],
|
||||
]);
|
||||
// Should NOT be cloned, already has tenant parameter + 'clone' middleware in group
|
||||
// 'clone' MW in groups won't be removed (this doesn't cause any issues)
|
||||
RouteFacade::middlewareGroup('group', ['auth', 'clone']);
|
||||
RouteFacade::get("/{tenant}/route-with-clone-in-mw-group", fn () => true)
|
||||
->middleware('group')
|
||||
->name("tenant.route-with-clone-in-mw-group");
|
||||
|
||||
// SHOULD be cloned (has clone middleware)
|
||||
RouteFacade::get('/foo', fn () => true)
|
||||
->middleware(['clone'])
|
||||
->name('foo');
|
||||
|
||||
// SHOULD be cloned (has nested clone middleware)
|
||||
RouteFacade::get('/bar', fn () => true)
|
||||
->middleware(['group'])
|
||||
->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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
||||
use Stancl\Tenancy\Database\Models;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
Route::group([
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData
|
|||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
|
|||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
InitializeTenancyByDomain::$onFail = null;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
|
|||
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware;
|
||||
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithRouteMiddleware;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set([
|
||||
|
|
@ -343,9 +344,9 @@ test('the tenant parameter is only removed from tenant routes when using path id
|
|||
->middleware('tenant')
|
||||
->name('tenant-route');
|
||||
|
||||
RouteFacade::get($pathIdentification ? '/universal-route' : '/universal-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter'])
|
||||
->middleware('universal')
|
||||
->name('universal-route');
|
||||
RouteFacade::get($pathIdentification ? '/cloned-route' : '/cloned-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter'])
|
||||
->middleware('clone')
|
||||
->name('cloned-route');
|
||||
|
||||
/** @var CloneRoutesAsTenant */
|
||||
$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();
|
||||
expect((bool) $response->getContent())->toBeFalse();
|
||||
|
||||
// The tenant parameter gets removed from the cloned universal route
|
||||
$response = pest()->get($tenantKey . '/universal-route')->assertOk();
|
||||
// The tenant parameter gets removed from the cloned route
|
||||
$response = pest()->get($tenantKey . '/cloned-route')->assertOk();
|
||||
expect((bool) $response->getContent())->toBeFalse();
|
||||
} else {
|
||||
// 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();
|
||||
expect((bool) $response->getContent())->toBeTrue();
|
||||
|
||||
// The tenant parameter does not get removed from the universal route when accessing it through the central domain
|
||||
$response = pest()->get("http://localhost/universal-route/$tenantKey")->assertOk();
|
||||
// The tenant parameter does not get removed from the cloned route when accessing it through the central domain
|
||||
$response = pest()->get("http://localhost/cloned-route/$tenantKey")->assertOk();
|
||||
expect((bool) $response->getContent())->toBeTrue();
|
||||
|
||||
// The tenant parameter gets removed from the universal route when accessing it through the tenant domain
|
||||
$response = pest()->get("http://{$domain}/universal-route")->assertOk();
|
||||
// The tenant parameter gets removed from the cloned route when accessing it through the tenant domain
|
||||
$response = pest()->get("http://{$domain}/cloned-route")->assertOk();
|
||||
expect((bool) $response->getContent())->toBeFalse();
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use Stancl\Tenancy\Events\BootstrappingTenancy;
|
|||
use Stancl\Tenancy\Listeners\QueueableListener;
|
||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
FooListener::$shouldQueue = false;
|
||||
|
|
|
|||
|
|
@ -18,14 +18,9 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
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') {
|
||||
// Escape testbench prison. Can't hardcode /var/www/html/extensions/... here
|
||||
// since GHA doesn't mount the filesystem on the container's workdir
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Features\CrossDomainRedirect;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
test('tenant redirect macro replaces only the hostname', function () {
|
||||
config([
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Stancl\Tenancy\Features\TenantConfig;
|
|||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
afterEach(function () {
|
||||
TenantConfig::$storageToConfigMap = [];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
|
|||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function() {
|
||||
config(['mail.default' => 'smtp']);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
|
|||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
|
|||
use Stancl\Tenancy\Listeners\CreateTenantConnection;
|
||||
use Stancl\Tenancy\Listeners\UseCentralConnection;
|
||||
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 () {
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
InitializeTenancyByOriginHeader::$onFail = null;
|
||||
|
|
@ -35,6 +36,12 @@ test('origin identification works', function () {
|
|||
->withHeader('Origin', 'foo.localhost')
|
||||
->post('home')
|
||||
->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 () {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,30 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
|
||||
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
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 () {
|
||||
// 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 () {
|
||||
Tenant::create([
|
||||
'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']);
|
||||
$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) {
|
||||
return "$a + $b + $team";
|
||||
})->middleware('central')->name('central-route');
|
||||
|
|
@ -184,8 +205,6 @@ test('the tenant model column can be customized in the config', function () {
|
|||
$this->withoutExceptionHandling();
|
||||
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
|
||||
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 () {
|
||||
|
|
@ -217,8 +236,6 @@ test('the tenant model column can be customized in the route definition', functi
|
|||
// Binding field defined
|
||||
pest()->get('/acme/bar')->assertSee($tenant->getTenantKey());
|
||||
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 () {
|
||||
|
|
@ -242,6 +259,39 @@ test('any extra model column needs to be whitelisted', function () {
|
|||
// After whitelisting the column it works
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use Stancl\Tenancy\Events\PendingTenantCreated;
|
|||
use Stancl\Tenancy\Events\PendingTenantPulled;
|
||||
use Stancl\Tenancy\Events\PullingPendingTenant;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
test('tenants are correctly identified as pending', function (){
|
||||
Tenant::createPending();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Stancl\Tenancy\Tests\TestCase;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
|
@ -8,14 +10,14 @@ use Stancl\Tenancy\Events\TenantCreated;
|
|||
|
||||
uses(TestCase::class)->in(__DIR__);
|
||||
|
||||
function pest(): TestCase
|
||||
{
|
||||
return Pest\TestSuite::getInstance()->test;
|
||||
}
|
||||
|
||||
function withTenantDatabases()
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
}
|
||||
|
||||
function pest(): TestCase
|
||||
{
|
||||
return \Pest\TestSuite::getInstance()->test;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
|
|||
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
||||
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) {
|
||||
config()->set([
|
||||
|
|
|
|||
|
|
@ -22,16 +22,14 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\PersistentQueueTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Listeners\QueueableListener;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->mockConsoleOutput = false;
|
||||
|
||||
config([
|
||||
'tenancy.bootstrappers' => [
|
||||
QueueTenancyBootstrapper::class,
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
],
|
||||
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
|
||||
'queue.default' => 'redis',
|
||||
]);
|
||||
|
||||
|
|
@ -45,7 +43,22 @@ afterEach(function () {
|
|||
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();
|
||||
|
||||
config(['queue.default' => 'sync']);
|
||||
|
|
@ -61,9 +74,10 @@ test('tenant id is passed to tenant queues', function () {
|
|||
Event::assertDispatched(JobProcessing::class, function ($event) {
|
||||
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();
|
||||
|
||||
$tenant = Tenant::create();
|
||||
|
|
@ -82,9 +96,10 @@ test('tenant id is not passed to central queues', function () {
|
|||
Event::assertDispatched(JobProcessing::class, function ($event) {
|
||||
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();
|
||||
withFailedJobs();
|
||||
|
||||
|
|
@ -117,7 +132,7 @@ test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) {
|
|||
$tenant->run(function () use ($user) {
|
||||
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 () {
|
||||
// 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();
|
||||
});
|
||||
|
||||
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();
|
||||
withTenantDatabases();
|
||||
|
||||
|
|
@ -189,9 +205,10 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
|
|||
$tenant->run(function () use ($user) {
|
||||
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();
|
||||
|
||||
$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');
|
||||
|
||||
expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant1->getTenantKey());
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
})->with('queue_bootstrappers');
|
||||
|
||||
// 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();
|
||||
|
||||
$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(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant->getTenantKey());
|
||||
});
|
||||
})->with('queue_bootstrappers');
|
||||
|
||||
function createValueStore(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
|||
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
CreateUserWithRLSPolicies::$forceRls = true;
|
||||
TraitRLSManager::$excludedModels = [Article::class];
|
||||
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
|
||||
test('rls command doesnt fail when a view is in the database', function (string $manager) {
|
||||
DB::statement("
|
||||
|
|
@ -183,7 +189,9 @@ test('rls command recreates policies if the force option is passed', function (s
|
|||
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]);
|
||||
|
||||
$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)
|
||||
VALUES ('post2', ?, ?)
|
||||
SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class);
|
||||
})->with([
|
||||
TableRLSManager::class,
|
||||
TraitRLSManager::class,
|
||||
]);
|
||||
})->with([TableRLSManager::class, TraitRLSManager::class])->with([true, false]);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,16 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Schema\ForeignIdColumnDefinition;
|
||||
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
|
||||
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
|
||||
|
||||
beforeEach(function () {
|
||||
CreateUserWithRLSPolicies::$forceRls = true;
|
||||
TableRLSManager::$scopeByDefault = true;
|
||||
|
||||
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() {
|
||||
$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
|
||||
Schema::create('notes', function (Blueprint $table) {
|
||||
Schema::create('notes', function (Blueprint $table) use ($commentConstraint) {
|
||||
$table->id();
|
||||
$table->string('text')->default('foo');
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
|
@ -180,9 +197,9 @@ test('queries are correctly scoped using RLS', function() {
|
|||
|
||||
$post1 = Post::create([
|
||||
'text' => 'first post',
|
||||
'tenant_id' => $tenant1->getTenantKey(),
|
||||
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->getTenantKey()])->id,
|
||||
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->getTenantKey()])->id,
|
||||
'tenant_id' => $tenant1->id,
|
||||
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->id])->id,
|
||||
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->id])->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([
|
||||
'text' => 'second post',
|
||||
'tenant_id' => $tenant2->getTenantKey(),
|
||||
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->getTenantKey()])->id,
|
||||
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->getTenantKey()])->id
|
||||
'tenant_id' => $tenant2->id,
|
||||
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->id])->id,
|
||||
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->id])->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);
|
||||
|
||||
// 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);
|
||||
|
||||
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})"))
|
||||
->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;
|
||||
|
||||
// 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 */
|
||||
$manager = app(TableRLSManager::class);
|
||||
|
||||
$expectedTrees = [
|
||||
$expectedShortestPaths = [
|
||||
'authors' => [
|
||||
// Directly related to tenants
|
||||
'tenant_id' => [
|
||||
[
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => '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,
|
||||
],
|
||||
],
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
],
|
||||
'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' => [
|
||||
[
|
||||
[
|
||||
'foreignKey' => 'author_id',
|
||||
'localColumn' => 'author_id',
|
||||
'foreignTable' => 'authors',
|
||||
'foreignId' => 'id',
|
||||
'nullable' => false,
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
'nullable' => false,
|
||||
]
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
],
|
||||
'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',
|
||||
'foreignId' => 'id',
|
||||
'nullable' => true,
|
||||
]
|
||||
]
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
],
|
||||
// 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
|
||||
|
|
@ -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
|
||||
];
|
||||
|
||||
expect($manager->generateTrees())->toEqual($expectedTrees);
|
||||
expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
|
||||
|
||||
$expectedShortestPaths = [
|
||||
'authors' => [
|
||||
// Add non-nullable comment_id comment constraint
|
||||
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',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
],
|
||||
'posts' => [
|
||||
[
|
||||
'foreignKey' => 'author_id',
|
||||
'foreignTable' => 'authors',
|
||||
'foreignId' => 'id',
|
||||
'localColumn' => 'comment_id',
|
||||
'foreignTable' => 'comments',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
],
|
||||
'comments' => [
|
||||
[
|
||||
'foreignKey' => 'post_id',
|
||||
'localColumn' => 'post_id',
|
||||
'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',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'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);
|
||||
})->with([true, false]);
|
||||
|
||||
// Only related to the tenants table through nullable columns – tenant_id and indirectly through post_id
|
||||
Schema::create('ratings', function (Blueprint $table) {
|
||||
// https://github.com/archtechx/tenancy/pull/1293
|
||||
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->integer('stars')->default(0);
|
||||
$table->string('name');
|
||||
|
||||
$table->unsignedBigInteger('post_id')->nullable()->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->string('tenant_id')->comment('rls');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// The shortest paths should include a path for the ratings table
|
||||
// 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();
|
||||
$tenant1 = Tenant::create();
|
||||
|
||||
$expectedShortestPath = $scopeByDefault ? [
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
] : [
|
||||
[
|
||||
'foreignKey' => 'post_id',
|
||||
'foreignTable' => 'posts',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
];
|
||||
// Create RLS policy for the orders table
|
||||
pest()->artisan('tenants:rls');
|
||||
|
||||
expect($shortestPaths['ratings'])->toBe($expectedShortestPath);
|
||||
$tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
|
||||
|
||||
// Add non-nullable comment_id foreign key
|
||||
Schema::table('ratings', function (Blueprint $table) {
|
||||
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments');
|
||||
// We are still using the 'administrator' user - owner of the orders table
|
||||
|
||||
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
|
||||
// The shortest paths should include a path for the ratings table
|
||||
// That leads through comment_id instead of tenant_id
|
||||
$shortestPaths = $manager->shortestPaths();
|
||||
$tenant1 = Tenant::create();
|
||||
|
||||
expect($shortestPaths['ratings'])->toBe([
|
||||
[
|
||||
'foreignKey' => 'comment_id',
|
||||
'foreignTable' => 'comments',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'post_id',
|
||||
'foreignTable' => 'posts',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'author_id',
|
||||
'foreignTable' => 'authors',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
],
|
||||
]);
|
||||
})->with([true, false]);
|
||||
// Create RLS policy for the orders table
|
||||
pest()->artisan('tenants:rls');
|
||||
|
||||
$tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
|
||||
|
||||
// We are still using the 'administrator' user
|
||||
|
||||
if ($bypassRls) {
|
||||
// Users with BYPASSRLS can always query tables regardless of forceRls setting
|
||||
expect(Order::count())->toBe(1);
|
||||
expect(Order::first()->name)->toBe('order1');
|
||||
} else {
|
||||
// Users without BYPASSRLS are subject to RLS policies even if they're table owners when forceRls is true
|
||||
// OR they can bypass as table owners (when forceRls=false)
|
||||
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"');
|
||||
} else {
|
||||
// Table owners can bypass RLS automatically when forceRls is false
|
||||
expect(Order::count())->toBe(1);
|
||||
expect(Order::first()->name)->toBe('order1');
|
||||
}
|
||||
}
|
||||
})->with([true, false])->with([true, false]);
|
||||
|
||||
test('table rls manager generates queries correctly', function() {
|
||||
expect(array_values(app(TableRLSManager::class)->generateQueries()))->toEqualCanonicalizing([
|
||||
|
|
@ -569,38 +613,38 @@ test('table rls manager generates queries correctly', function() {
|
|||
$paths = [
|
||||
'primaries' => [
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
],
|
||||
'secondaries' => [
|
||||
[
|
||||
'foreignKey' => 'primary_id',
|
||||
'localColumn' => 'primary_id',
|
||||
'foreignTable' => 'primaries',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'foreignTable' => 'tenants',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
],
|
||||
'foo' => [
|
||||
[
|
||||
'foreignKey' => 'secondary_id',
|
||||
'localColumn' => 'secondary_id',
|
||||
'foreignTable' => 'secondaries',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'primary_id',
|
||||
'localColumn' => 'primary_id',
|
||||
'foreignTable' => 'primaries',
|
||||
'foreignId' => 'id',
|
||||
'foreignColumn' => 'id',
|
||||
],
|
||||
[
|
||||
'foreignKey' => 'tenant_id',
|
||||
'localColumn' => 'tenant_id',
|
||||
'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) {
|
||||
$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) {
|
||||
$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
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
|
@ -701,3 +906,8 @@ class Author extends Model
|
|||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
|||
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
CreateUserWithRLSPolicies::$forceRls = true;
|
||||
TraitRLSManager::$implicitRLS = true;
|
||||
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
|
||||
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 () {
|
||||
$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();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
$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})"))
|
||||
->toThrow(QueryException::class);
|
||||
})->with([
|
||||
true,
|
||||
false
|
||||
]);
|
||||
})->with([true, false])->with([true, false]);
|
||||
|
||||
test('trait rls manager generates queries correctly', function() {
|
||||
/** @var TraitRLSManager $manager */
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
@ -14,45 +17,90 @@ beforeEach(function () {
|
|||
],
|
||||
]);
|
||||
|
||||
InitializeTenancyByRequestData::$header = 'X-Tenant';
|
||||
InitializeTenancyByRequestData::$cookie = 'X-Tenant';
|
||||
InitializeTenancyByRequestData::$queryParameter = 'tenant';
|
||||
|
||||
Route::middleware(['tenant', InitializeTenancyByRequestData::class])->get('/test', function () {
|
||||
Route::middleware([InitializeTenancyByRequestData::class])->get('/test', function () {
|
||||
return 'Tenant id: ' . tenant('id');
|
||||
});
|
||||
});
|
||||
|
||||
test('header identification works', function () {
|
||||
$tenant = Tenant::create();
|
||||
|
||||
$this
|
||||
->withoutExceptionHandling()
|
||||
->withHeader('X-Tenant', $tenant->id)
|
||||
->get('test')
|
||||
->assertSee($tenant->id);
|
||||
test('header identification works', function (string|null $tenantModelColumn) {
|
||||
if ($tenantModelColumn) {
|
||||
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
|
||||
$table->string($tenantModelColumn)->unique();
|
||||
});
|
||||
Tenant::$extraCustomColumns = [$tenantModelColumn];
|
||||
}
|
||||
|
||||
test('query parameter identification works', function () {
|
||||
$tenant = Tenant::create();
|
||||
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
|
||||
|
||||
$this
|
||||
->withoutExceptionHandling()
|
||||
->get('test?tenant=' . $tenant->id)
|
||||
->assertSee($tenant->id);
|
||||
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
|
||||
$payload = $tenantModelColumn ? 'acme' : $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 () {
|
||||
$tenant = Tenant::create();
|
||||
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
|
||||
|
||||
$this
|
||||
->withoutExceptionHandling()
|
||||
->withUnencryptedCookie('X-Tenant', $tenant->id)
|
||||
->get('test')
|
||||
->assertSee($tenant->id);
|
||||
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
|
||||
$payload = $tenantModelColumn ? 'acme' : $tenant->id;
|
||||
|
||||
// Default query parameter name
|
||||
$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);
|
||||
$this->withoutExceptionHandling()->get('test');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
|
|||
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser;
|
||||
use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException;
|
||||
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
use Illuminate\Database\QueryException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['tenancy.bootstrappers' => [
|
||||
|
|
@ -67,6 +70,9 @@ beforeEach(function () {
|
|||
DeleteResourceInTenant::$shouldQueue = false;
|
||||
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
|
||||
|
||||
// Reset global scopes on models (should happen automatically but to make this more explicit)
|
||||
Model::clearBootedModels();
|
||||
|
||||
$syncedAttributes = [
|
||||
'global_id',
|
||||
'name',
|
||||
|
|
@ -105,6 +111,30 @@ beforeEach(function () {
|
|||
|
||||
afterEach(function () {
|
||||
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 () {
|
||||
|
|
@ -1172,6 +1202,69 @@ test('resource creation works correctly when central resource provides defaults
|
|||
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.
|
||||
*
|
||||
|
|
@ -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
|
||||
{
|
||||
public $table = 'tenant_users';
|
||||
|
|
@ -1320,6 +1421,7 @@ class CentralCompany extends Model implements SyncMaster
|
|||
];
|
||||
}
|
||||
}
|
||||
|
||||
class TenantCompany extends Model implements Syncable
|
||||
{
|
||||
use ResourceSyncing;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,18 @@ test('tenancy detects presence of route middleware correctly', function (string
|
|||
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() {
|
||||
$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
Loading…
Add table
Add a link
Reference in a new issue