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

Compare commits

...

11 commits

Author SHA1 Message Date
lukinovec
53c7d4988c Add DeleteAllTenantMappings listener 2025-11-14 11:21:28 +01:00
lukinovec
ff95c92134 Add SyncedResourceDeleted event and DeleteResourceMapping listener
Also move pivot record deletion to that listener and improve tests
2025-11-14 11:21:28 +01:00
45cf7029af
globalUrl: useAssetOrigin() instead of setAssetRoot()
This change was prompted by a phpstan failure after a recent update.
While making this change, I noticed we don't need the macro anymore
as useAssetOrigin() was added to the UrlGenerator earlier this year,
simplifying our implementation.
2025-11-14 10:59:31 +01:00
0cd0bc44b1
config: ignore port in default central_domains value
Recent Laravel installations often have http://localhost:8000 as
APP_URL, so we make sure to strip any port suffix from the default
central domain derived from APP_URL.
2025-11-11 02:06:13 +01:00
38aab013a4
Merge pull request #1416 from archtechx/cloning-improvements
[4.x] Route cloning improvements
2025-11-10 22:04:02 +01:00
2aca784c0b
Cloning: remove comments in TSP stub in favor of referencing class docs 2025-11-10 17:31:37 +01:00
6ef4b91744
Cloning: improve type annotations, add cloneRoutes() for convenience 2025-11-10 02:16:57 +01:00
197513dd84
Cloning: addTenantMiddleware() for specifying ID MW for cloned route
Previously, tenant identification middleware was typically specified
for the cloned route by "inheriting" it from the central route, which
necessarily meant that the central route had to also be marked as
universal so it could continue working in the central context --
despite presumably not being usable in the tenant context, thus being
universal for no proper reason. In such cases, universal routes were
used mainly as a mechanism for specifying the tenant identification
middleware to use on the cloned tenant route.

Given that recent refactors of the cloning feature have made it more
customizable and a bit nicer to use "multiple times", i.e. run handle()
with a few different configurations of the action, letting the
developer specify the used tenant middleware using a method like this
only makes sense.

The feature also becomes more independently usable and not just a
"hack for universal routes with path identification".
2025-11-09 00:27:14 +01:00
97c5afd2cf
Cloning: clarify case where neither paths nor domains differ
In such a case, the cloned route will actually *override* the original
route, rather than being unused as the original docblock claimed.

Also adds a static make() function for convenience.
2025-11-08 20:38:01 +01:00
69bf768424
Cloning: remove route context middleware flags during cloning
Previously, if a universal route was cloned without a
cloneRoutesWithMiddleware(['universal']) call, i.e. it had both
'clone' and 'universal' flags, with only the former triggering cloning,
the 'universal' flag would be included in the middleware of the cloned
route.

Now, we make sure to remove all context flags -- central, tenant,
universal -- in the first step of processing middleware, before adding
just 'tenant'.
2025-11-08 01:17:15 +01:00
Hayatunnabi Nabil
947894fa1d
[4.x] Fix dropRLSPolicies() (#1413)
`?` parameters are not supported in these statements, so we have to use
string interpolation like in other related code.

---------

Co-authored-by: Samuel Štancl <samuel@archte.ch>
2025-11-08 00:52:08 +01:00
19 changed files with 408 additions and 71 deletions

View file

@ -81,6 +81,8 @@ class TenancyServiceProvider extends ServiceProvider
])->send(function (Events\TenantDeleted $event) {
return $event->tenant;
})->shouldBeQueued(false),
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
],
Events\TenantMaintenanceModeEnabled::class => [],
@ -129,6 +131,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
],
ResourceSyncing\Events\SyncedResourceDeleted::class => [
ResourceSyncing\Listeners\DeleteResourceMapping::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
],
@ -242,24 +247,7 @@ class TenancyServiceProvider extends ServiceProvider
/** @var CloneRoutesAsTenant $cloneRoutes */
$cloneRoutes = $this->app->make(CloneRoutesAsTenant::class);
// 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.
/** See CloneRoutesAsTenant for usage details. */
$cloneRoutes->handle();
}

View file

@ -64,7 +64,7 @@ return [
* Only relevant if you're using the domain or subdomain identification middleware.
*/
'central_domains' => [
str(env('APP_URL'))->after('://')->before('/')->toString(),
str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(),
],
/**

View file

@ -46,10 +46,6 @@ parameters:
message: '#PHPDoc tag \@param has invalid value \(dynamic#'
paths:
- src/helpers.php
-
message: '#Illuminate\\Routing\\UrlGenerator#'
paths:
- src/Bootstrappers/FilesystemTenancyBootstrapper.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#'

View file

@ -30,6 +30,8 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
* 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.
*
* Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'.
* The addition of the 'tenant' middleware can be controlled using addTenantMiddleware(array). You can specify the identification
* middleware to be used on the cloned route using that method -- instead of using the approach that "inherits" it from a universal route.
*
* The addition of the tenant parameter can be controlled using addTenantParameter(true|false). Note that if you decide to disable
* tenant parameter addition, the routes MUST differ in domains. This can be controlled using the domain(string|null) method. The
@ -39,7 +41,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
* 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.
*
* After cloning, only top-level middleware in $cloneRoutesWithMiddleware will be removed
* After cloning, only top-level middleware in $cloneRoutesWithMiddleware (as well as any route context flags) 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.
*
@ -71,7 +73,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
* // cloned route can be customized using domain(string|null). By default, the cloned route will not be scoped to a domain,
* // unless a domain() call is used. It's important to keep in mind that:
* // 1. When addTenantParameter(false) is used, the paths will be the same, thus domains must differ.
* // 2. If the original route (with the same path) has no domain, the cloned route will never be used due to registration order.
* // 2. If the original route has no domain, the cloned route will override the original route as they will directly conflict.
* $cloneAction->addTenantParameter(false)->cloneRoutesWithMiddleware(['clone'])->cloneRoute('no-tenant-parameter')->handle();
* ```
*
@ -84,27 +86,50 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
*/
class CloneRoutesAsTenant
{
/** @var list<Route|string> */
protected array $routesToClone = [];
protected bool $addTenantParameter = true;
protected bool $tenantParameterBeforePrefix = true;
protected string|null $domain = null;
protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
/**
* The callback should accept a Route instance or the route name (string).
*
* @var ?Closure(Route|string): void
*/
protected Closure|null $cloneUsing = null;
/** @var ?Closure(Route): bool */
protected Closure|null $shouldClone = null;
/** @var list<string> */
protected array $cloneRoutesWithMiddleware = ['clone'];
/** @var list<string> */
protected array $addTenantMiddleware = ['tenant'];
public function __construct(
protected Router $router,
) {}
public static function make(): static
{
return app(static::class);
}
/** Clone routes. This resets routesToClone() but not other config. */
public function handle(): void
{
// 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())
/** @var list<Route> */
$routesToClone = collect($this->router->getRoutes()->get())
->filter(fn (Route $route) => $this->shouldBeCloned($route))
->all();
$this->routesToClone = $routesToClone;
}
foreach ($this->routesToClone as $route) {
@ -118,7 +143,9 @@ class CloneRoutesAsTenant
if (is_string($route)) {
$this->router->getRoutes()->refreshNameLookups();
$route = $this->router->getRoutes()->getByName($route);
$routeName = $route;
$route = $this->router->getRoutes()->getByName($routeName);
assert(! is_null($route), "Route [{$routeName}] was meant to be cloned but does not exist.");
}
$this->createNewRoute($route);
@ -143,6 +170,20 @@ class CloneRoutesAsTenant
return $this;
}
/**
* The tenant middleware to be added to the cloned route.
*
* If used with early identification, make sure to include 'tenant' in this array.
*
* @param list<string> $middleware
*/
public function addTenantMiddleware(array $middleware): static
{
$this->addTenantMiddleware = $middleware;
return $this;
}
/** The domain the cloned route should use. Set to null if it shouldn't be scoped to a domain. */
public function domain(string|null $domain): static
{
@ -151,7 +192,11 @@ class CloneRoutesAsTenant
return $this;
}
/** Provide a custom callback for cloning routes, instead of the default behavior. */
/**
* Provide a custom callback for cloning routes, instead of the default behavior.
*
* @param ?Closure(Route|string): void $cloneUsing
*/
public function cloneUsing(Closure|null $cloneUsing): static
{
$this->cloneUsing = $cloneUsing;
@ -159,7 +204,11 @@ class CloneRoutesAsTenant
return $this;
}
/** Specify which middleware should serve as "flags" telling this action to clone those routes. */
/**
* Specify which middleware should serve as "flags" telling this action to clone those routes.
*
* @param list<string> $middleware
*/
public function cloneRoutesWithMiddleware(array $middleware): static
{
$this->cloneRoutesWithMiddleware = $middleware;
@ -170,7 +219,9 @@ class CloneRoutesAsTenant
/**
* Provide a custom callback for determining whether a route should be cloned.
* Overrides the default middleware-based detection.
* */
*
* @param Closure(Route): bool $shouldClone
*/
public function shouldClone(Closure|null $shouldClone): static
{
$this->shouldClone = $shouldClone;
@ -193,6 +244,18 @@ class CloneRoutesAsTenant
return $this;
}
/**
* Clone individual routes.
*
* @param list<Route|string> $routes
*/
public function cloneRoutes(array $routes): static
{
$this->routesToClone = array_merge($this->routesToClone, $routes);
return $this;
}
protected function shouldBeCloned(Route $route): bool
{
// Don't clone routes that already have tenant parameter or prefix
@ -258,17 +321,15 @@ class CloneRoutesAsTenant
return $newRoute;
}
/** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */
/** Removes top-level cloneRoutesWithMiddleware and context flags, adds 'tenant' middleware. */
protected function processMiddlewareForCloning(array $middleware): array
{
$processedMiddleware = array_filter(
$middleware,
fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware)
fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) && ! in_array($mw, ['central', 'tenant', 'universal'])
);
$processedMiddleware[] = 'tenant';
return array_unique($processedMiddleware);
return array_unique(array_merge($processedMiddleware, $this->addTenantMiddleware));
}
/** Check if route already has tenant parameter or name prefix. */

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Session\FileSessionHandler;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
@ -22,13 +21,6 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
) {
$this->originalAssetUrl = $this->app['config']['app.asset_url'];
$this->originalStoragePath = $app->storagePath();
$this->app['url']->macro('setAssetRoot', function ($root) {
/** @var UrlGenerator $this */
$this->assetRoot = $root;
return $this;
});
}
public function bootstrap(Tenant $tenant): void
@ -107,16 +99,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
if ($suffix === false) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl;
$this->app['url']->setAssetRoot($this->originalAssetUrl);
$this->app['url']->useAssetOrigin($this->originalAssetUrl);
return;
}
if ($this->originalAssetUrl) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
$this->app['url']->useAssetOrigin($this->app['config']['app.asset_url']);
} else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
$this->app['url']->useAssetOrigin($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
}
}

View file

@ -26,7 +26,7 @@ trait ManagesRLSPolicies
$policies = static::getRLSPolicies($table);
foreach ($policies as $policy) {
DB::statement('DROP POLICY ? ON ?', [$policy, $table]);
DB::statement("DROP POLICY {$policy} ON {$table}");
}
return count($policies);

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class SyncedResourceDeleted
{
public function __construct(
public Syncable&Model $model,
public TenantWithDatabase|null $tenant,
public bool $forceDelete,
) {}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Listeners\QueueableListener;
/**
* Clean up pivot records related to the deleted tenant.
* The listener only cleans up the pivot tables specified
* in the $pivotTables property (see the property for details),
* and is intended for use with tables that do not have tenant foreign key constraints.
*
* When using foreign key constraints, you'll still have to use ->onDelete('cascade')
* on the constraint (otherwise, deleting a tenant will throw a foreign key constraint violation).
* That way, the cleanup will happen on the database level, and this listener will essentially
* just perform an extra 'where' query.
*/
class DeleteAllTenantMappings extends QueueableListener
{
/**
* Pivot tables to clean up after a tenant is deleted,
* formatted like ['table_name' => 'tenant_key_column'].
*
* Since we cannot automatically detect which pivot tables
* you want to clean up, they have to be specified here.
*
* By default, resource syncing uses the tenant_resources table, and the records are associated
* to tenants by the tenant_id column (thus the ['tenant_resources' => 'tenant_id'] default).
*
* To customize this, set this property, e.g. in TenancyServiceProvider:
* DeleteAllTenantMappings::$pivotTables = [
* 'tenant_users' => 'tenant_id',
* // You can also add more pivot tables here
* ];
*
* Non-existent tables specified in the property will be skipped.
*/
public static array $pivotTables = ['tenant_resources' => 'tenant_id'];
public function handle(TenantDeleted $event): void
{
foreach (static::$pivotTables as $table => $tenantKeyColumn) {
if (Schema::hasTable($table)) {
DB::table($table)->where($tenantKeyColumn, $event->tenant->getTenantKey())->delete();
}
}
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
/**
* Deletes pivot records when a synced resource is deleted.
*
* If a SyncMaster (central resource) is deleted, all pivot records for that resource are deleted.
* If a Syncable (tenant resource) is deleted, only delete the pivot record for that tenant.
*/
class DeleteResourceMapping extends QueueableListener
{
public static bool $shouldQueue = false;
public function handle(SyncedResourceDeleted $event): void
{
$centralResource = $this->getCentralResource($event->model);
if (! $centralResource) {
return;
}
// Delete pivot records if the central resource doesn't use soft deletes
// or the central resource was deleted using forceDelete()
if ($event->forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) {
Pivot::withoutEvents(function () use ($centralResource, $event) {
// $event->tenant is null when the deleted resource is a SyncMaster - all mappings are deleted in that case
// When $event->tenant is not null (= a Syncable was deleted), only delete the mapping for that tenant
$centralResource->tenants()->detach($event->tenant);
});
}
}
public function getCentralResource(Syncable&Model $resource): SyncMaster|null
{
if ($resource instanceof SyncMaster) {
return $resource;
}
$centralResourceClass = $resource->getCentralModelName();
/** @var (SyncMaster&Model)|null $centralResource */
$centralResource = $centralResourceClass::firstWhere(
$resource->getGlobalIdentifierKeyName(),
$resource->getGlobalIdentifierKey()
);
return $centralResource;
}
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener
tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) {
$this->deleteSyncedResource($centralResource, $forceDelete);
// Delete pivot records if the central resource doesn't use soft deletes
// or the central resource was deleted using forceDelete()
if ($forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) {
$centralResource->tenants()->detach(tenant());
}
});
}
}

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
@ -25,8 +26,8 @@ trait ResourceSyncing
}
});
static::deleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
static::deleted(function (Syncable&Model $model) {
if ($model->shouldSync()) {
$model->triggerDeleteEvent();
}
});
@ -42,13 +43,13 @@ trait ResourceSyncing
if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) {
static::forceDeleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
if ($model->shouldSync()) {
$model->triggerDeleteEvent(true);
}
});
static::restoring(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
if ($model->shouldSync()) {
$model->triggerRestoredEvent();
}
});
@ -67,6 +68,8 @@ trait ResourceSyncing
/** @var SyncMaster&Model $this */
event(new SyncMasterDeleted($this, $forceDelete));
}
event(new SyncedResourceDeleted($this, tenant(), $forceDelete));
}
public function triggerRestoredEvent(): void

View file

@ -25,7 +25,5 @@ interface SyncMaster extends Syncable
public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void;
public function triggerDeleteEvent(bool $forceDelete = false): void;
public function triggerRestoredEvent(): void;
}

View file

@ -16,6 +16,8 @@ interface Syncable
public function triggerSyncEvent(): void;
public function triggerDeleteEvent(bool $forceDelete = false): void;
/**
* Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one).
*

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy;
use Closure;
use Illuminate\Cache\CacheManager;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
@ -157,12 +158,13 @@ class TenancyServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/../assets/routes.php');
}
$this->app->singleton('globalUrl', function ($app) {
$this->app->singleton('globalUrl', function (Container $app) {
if ($app->bound(FilesystemTenancyBootstrapper::class)) {
$instance = clone $app['url'];
$instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalAssetUrl);
/** @var \Illuminate\Routing\UrlGenerator */
$instance = clone $app->make('url');
$instance->useAssetOrigin($app->make(FilesystemTenancyBootstrapper::class)->originalAssetUrl);
} else {
$instance = $app['url'];
$instance = $app->make('url');
}
return $instance;

View file

@ -401,3 +401,77 @@ test('tenant parameter addition can be controlled by setting addTenantParameter'
$this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
}
})->with([true, false]);
test('existing context flags are removed during cloning', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone', 'central']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone', 'universal']);
$cloneAction = app(CloneRoutesAsTenant::class);
// Clone foo route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('central');
// Clone bar route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('universal');
});
test('cloning a route without a prefix or differing domains overrides the original route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('foo');
$cloneAction = CloneRoutesAsTenant::make();
$cloneAction->cloneRoute('foo')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo');
});
test('addTenantMiddleware can be used to specify the tenant middleware for the cloned route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
$cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction->cloneRoute('foo')->addTenantMiddleware([InitializeTenancyByPath::class])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
$cloned = RouteFacade::getRoutes()->getByName('tenant.foo');
expect($cloned->uri())->toBe('{tenant}/foo');
expect($cloned->getName())->toBe('tenant.foo');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByPath::class]);
$cloneAction->cloneRoute('bar')
->addTenantMiddleware([InitializeTenancyByDomain::class])
->domain('foo.localhost')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
$cloned = RouteFacade::getRoutes()->getByName('tenant.bar');
expect($cloned->uri())->toBe('bar');
expect($cloned->getName())->toBe('tenant.bar');
expect($cloned->getDomain())->toBe('foo.localhost');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]);
});
test('cloneRoutes can be used to clone multiple routes', function () {
RouteFacade::get('/foo', fn () => true)->name('foo');
$bar = RouteFacade::get('/bar', fn () => true)->name('bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
CloneRoutesAsTenant::make()->cloneRoutes(['foo', $bar])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('tenant.baz');
});

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster
{
use ResourceSyncing, CentralConnection;
protected $guarded = [];
public $timestamps = false;

View file

@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration
$table->string('global_user_id');
$table->unique(['tenant_id', 'global_user_id']);
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade');
});
}

View file

@ -17,6 +17,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Tenancy;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class,
]);
test('dropRLSPolicies only drops RLS policies', function () {
DB::statement('CREATE POLICY "comments_dummy_rls_policy" ON comments USING (true)');
DB::statement('CREATE POLICY "comments_foo_policy" ON comments USING (true)'); // non-RLS policy
$policyCount = fn () => count(DB::select("SELECT policyname FROM pg_policies WHERE tablename = 'comments'"));
expect($policyCount())->toBe(2);
$removed = Tenancy::dropRLSPolicies('comments');
expect($removed)->toBe(1);
// Only the non-RLS policy remains
expect($policyCount())->toBe(1);
});
test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;

View file

@ -46,6 +46,12 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping;
beforeEach(function () {
config(['tenancy.bootstrappers' => [
@ -69,6 +75,7 @@ beforeEach(function () {
CreateTenantResource::$shouldQueue = false;
DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
@ -92,6 +99,7 @@ beforeEach(function () {
CentralUser::$creationAttributes = $creationAttributes;
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class);
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
@ -890,7 +898,54 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $
'basic pivot' => false,
]);
test('tenant pivot records are deleted along with the tenants to which they belong to', function() {
test('tenant pivot records are deleted along with the tenants to which they belong to', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) {
[$tenant] = createTenantsAndRunMigrations();
if ($morphPivot) {
config(['tenancy.models.tenant' => MorphTenant::class]);
$centralUserModel = BaseCentralUser::class;
// The default pivot table, no need to configure the listener
$pivotTable = 'tenant_resources';
} else {
$centralUserModel = CentralUser::class;
// Custom pivot table
$pivotTable = 'tenant_users';
}
if ($dbLevelOnCascadeDelete) {
addTenantIdConstraintToPivot($pivotTable);
} else {
// Event-based cleanup
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id'];
}
$syncMaster = $centralUserModel::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$syncMaster->tenants()->attach($tenant);
// Pivot records should be deleted along with the tenant
$tenant->delete();
expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
})->with([
'db level on cascade delete' => true,
'event-based on cascade delete' => false,
])->with([
'polymorphic pivot' => true,
'basic pivot' => false,
]);
test('pivot record is automatically deleted with the tenant resource', function() {
[$tenant] = createTenantsAndRunMigrations();
$syncMaster = CentralUser::create([
@ -903,12 +958,32 @@ test('tenant pivot records are deleted along with the tenants to which they belo
$syncMaster->tenants()->attach($tenant);
$tenant->delete();
$tenant->run(function () {
TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant deletes its pivot records
// Deleting tenant resource deletes its pivot record
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
});
test('DeleteAllTenantMappings handles incorrect configuration correctly', function() {
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
// Existing table, non-existent tenant key column
// The listener should throw an 'unknown column' exception
DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column'];
// Should throw an exception when tenant is deleted
expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'");
// Non-existent table, the listener skips it, no exception to throw
DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column'];
expect(fn() => $tenant2->delete())->not()->toThrow(Exception::class);
});
test('trashed resources are synced correctly', function () {
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
migrateUsersTableForTenants();
@ -1265,6 +1340,13 @@ test('global scopes on syncable models can break resource syncing', function ()
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
});
function addTenantIdConstraintToPivot(string $pivotTable): void
{
Schema::table($pivotTable, function (Blueprint $table) {
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
/**
* Create two tenants and run migrations for those tenants.
*