1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-04 09:04:03 +00:00

Merge master, add more assertions, use array_merge() syntax

This commit is contained in:
Samuel Stancl 2025-12-16 22:56:34 +01:00
commit 12b3b50230
No known key found for this signature in database
GPG key ID: BA146259A1E16C57
45 changed files with 856 additions and 201 deletions

View file

@ -53,5 +53,5 @@ If you want to use XDebug, use `composer docker-rebuild-with-xdebug`.
## PHP 8.5 ## PHP 8.5
To use PHP 8.5 during development, run: To use PHP 8.5 during development, run:
- `PHP_VERSION=8.5.0RC2 composer docker-rebuild` to build the `test` container with PHP 8.5 - `PHP_VERSION=8.5.0 composer docker-rebuild` to build the `test` container with PHP 8.5
- `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core - `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core

View file

@ -19,9 +19,9 @@ You won't have to change a thing in your application's code.
- :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes - :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes
- :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains) - :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains)
### [Documentation](https://tenancy-v4.pages.dev/) ### [Documentation](https://v4.tenancyforlaravel.com)
Documentation can be found here: https://tenancy-v4.pages.dev/ Documentation can be found here: https://v4.tenancyforlaravel.com
### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md) ### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md)

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Routing\Route;
use Stancl\Tenancy\Jobs; use Stancl\Tenancy\Jobs;
use Stancl\Tenancy\Events; use Stancl\Tenancy\Events;
use Stancl\Tenancy\ResourceSyncing; use Stancl\Tenancy\ResourceSyncing;
@ -14,12 +13,8 @@ use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
/** /**
* Tenancy for Laravel. * Tenancy for Laravel.
@ -81,6 +76,8 @@ class TenancyServiceProvider extends ServiceProvider
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), })->shouldBeQueued(false),
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
], ],
Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeEnabled::class => [],
@ -129,6 +126,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
], ],
ResourceSyncing\Events\SyncedResourceDeleted::class => [
ResourceSyncing\Listeners\DeleteResourceMapping::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
], ],
@ -141,7 +141,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [
ResourceSyncing\Listeners\DeleteResourceInTenant::class, ResourceSyncing\Listeners\DeleteResourceInTenant::class,
], ],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
// Fired only when a synced resource is changed (as a result of syncing)
// in a different DB than DB from which the change originates (to avoid infinite loops)
ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [],
// Storage symlinks // Storage symlinks
@ -200,7 +202,7 @@ class TenancyServiceProvider extends ServiceProvider
// // To make Livewire v3 work with Tenancy, make the update route universal. // // To make Livewire v3 work with Tenancy, make the update route universal.
// Livewire::setUpdateRoute(function ($handle) { // Livewire::setUpdateRoute(function ($handle) {
// return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); // return Route::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]);
// }); // });
} }
@ -221,7 +223,7 @@ class TenancyServiceProvider extends ServiceProvider
{ {
$this->app->booted(function () { $this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) { if (file_exists(base_path('routes/tenant.php'))) {
RouteFacade::namespace(static::$controllerNamespace) Route::namespace(static::$controllerNamespace)
->middleware('tenant') ->middleware('tenant')
->group(base_path('routes/tenant.php')); ->group(base_path('routes/tenant.php'));
} }
@ -242,24 +244,7 @@ class TenancyServiceProvider extends ServiceProvider
/** @var CloneRoutesAsTenant $cloneRoutes */ /** @var CloneRoutesAsTenant $cloneRoutes */
$cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class);
// The cloning action has two modes: /** See CloneRoutesAsTenant for usage details. */
// 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(); $cloneRoutes->handle();
} }

View file

@ -48,6 +48,8 @@ return [
* SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs). * SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs).
* *
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator
@ -62,7 +64,7 @@ return [
* Only relevant if you're using the domain or subdomain identification middleware. * Only relevant if you're using the domain or subdomain identification middleware.
*/ */
'central_domains' => [ 'central_domains' => [
str(env('APP_URL'))->after('://')->before('/')->toString(), str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(),
], ],
/** /**
@ -311,7 +313,7 @@ return [
* *
* Note: This will implicitly add your configured session store to the list of prefixed stores above. * Note: This will implicitly add your configured session store to the list of prefixed stores above.
*/ */
'scope_sessions' => true, 'scope_sessions' => in_array(env('SESSION_DRIVER'), ['redis', 'memcached', 'dynamodb', 'apc'], true),
'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
], ],
@ -444,7 +446,6 @@ return [
/** /**
* Pending tenants config. * Pending tenants config.
* This is useful if you're looking for a way to always have a tenant ready to be used.
*/ */
'pending' => [ 'pending' => [
/** /**
@ -453,6 +454,7 @@ return [
* Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.) * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.)
*/ */
'include_in_queries' => true, 'include_in_queries' => true,
/** /**
* Defines how many pending tenants you want to have ready in the pending tenant pool. * Defines how many pending tenants you want to have ready in the pending tenant pool.
* This depends on the volume of tenants you're creating. * This depends on the volume of tenants you're creating.

View file

@ -46,10 +46,6 @@ parameters:
message: '#PHPDoc tag \@param has invalid value \(dynamic#' message: '#PHPDoc tag \@param has invalid value \(dynamic#'
paths: paths:
- src/helpers.php - src/helpers.php
-
message: '#Illuminate\\Routing\\UrlGenerator#'
paths:
- src/Bootstrappers/FilesystemTenancyBootstrapper.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#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\:\:\$tenancy#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' - '#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. * 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.'. * 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 * 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 * 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. * 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. * 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). * 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. * 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, * // 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: * // 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. * // 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(); * $cloneAction->addTenantParameter(false)->cloneRoutesWithMiddleware(['clone'])->cloneRoute('no-tenant-parameter')->handle();
* ``` * ```
* *
@ -84,27 +86,50 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
*/ */
class CloneRoutesAsTenant class CloneRoutesAsTenant
{ {
/** @var list<Route|string> */
protected array $routesToClone = []; protected array $routesToClone = [];
protected bool $addTenantParameter = true; protected bool $addTenantParameter = true;
protected bool $tenantParameterBeforePrefix = true; protected bool $tenantParameterBeforePrefix = true;
protected string|null $domain = null; 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; protected Closure|null $shouldClone = null;
/** @var list<string> */
protected array $cloneRoutesWithMiddleware = ['clone']; protected array $cloneRoutesWithMiddleware = ['clone'];
/** @var list<string> */
protected array $addTenantMiddleware = ['tenant'];
public function __construct( public function __construct(
protected Router $router, protected Router $router,
) {} ) {}
public static function make(): static
{
return app(static::class);
}
/** Clone routes. This resets routesToClone() but not other config. */ /** Clone routes. This resets routesToClone() but not other config. */
public function handle(): void public function handle(): void
{ {
// If no routes were specified using cloneRoute(), get all routes // If no routes were specified using cloneRoute(), get all routes
// and for each, determine if it should be cloned // and for each, determine if it should be cloned
if (! $this->routesToClone) { 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)) ->filter(fn (Route $route) => $this->shouldBeCloned($route))
->all(); ->all();
$this->routesToClone = $routesToClone;
} }
foreach ($this->routesToClone as $route) { foreach ($this->routesToClone as $route) {
@ -118,7 +143,9 @@ class CloneRoutesAsTenant
if (is_string($route)) { if (is_string($route)) {
$this->router->getRoutes()->refreshNameLookups(); $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); $this->createNewRoute($route);
@ -143,6 +170,20 @@ class CloneRoutesAsTenant
return $this; 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. */ /** 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 public function domain(string|null $domain): static
{ {
@ -151,7 +192,11 @@ class CloneRoutesAsTenant
return $this; 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 public function cloneUsing(Closure|null $cloneUsing): static
{ {
$this->cloneUsing = $cloneUsing; $this->cloneUsing = $cloneUsing;
@ -159,7 +204,11 @@ class CloneRoutesAsTenant
return $this; 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 public function cloneRoutesWithMiddleware(array $middleware): static
{ {
$this->cloneRoutesWithMiddleware = $middleware; $this->cloneRoutesWithMiddleware = $middleware;
@ -170,7 +219,9 @@ class CloneRoutesAsTenant
/** /**
* Provide a custom callback for determining whether a route should be cloned. * Provide a custom callback for determining whether a route should be cloned.
* Overrides the default middleware-based detection. * Overrides the default middleware-based detection.
* */ *
* @param Closure(Route): bool $shouldClone
*/
public function shouldClone(Closure|null $shouldClone): static public function shouldClone(Closure|null $shouldClone): static
{ {
$this->shouldClone = $shouldClone; $this->shouldClone = $shouldClone;
@ -193,6 +244,18 @@ class CloneRoutesAsTenant
return $this; 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 protected function shouldBeCloned(Route $route): bool
{ {
// Don't clone routes that already have tenant parameter or prefix // Don't clone routes that already have tenant parameter or prefix
@ -258,17 +321,15 @@ class CloneRoutesAsTenant
return $newRoute; 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 protected function processMiddlewareForCloning(array $middleware): array
{ {
$processedMiddleware = array_filter( $processedMiddleware = array_filter(
$middleware, $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(array_merge($processedMiddleware, $this->addTenantMiddleware));
return array_unique($processedMiddleware);
} }
/** Check if route already has tenant parameter or name prefix. */ /** Check if route already has tenant parameter or name prefix. */

View file

@ -15,7 +15,7 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
class BroadcastingConfigBootstrapper implements TenancyBootstrapper class BroadcastingConfigBootstrapper implements TenancyBootstrapper
{ {
/** /**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature). * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper).
* *
* For example: * For example:
* [ * [

View file

@ -102,14 +102,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
if ($this->config->get('tenancy.cache.scope_sessions', true)) { if ($this->config->get('tenancy.cache.scope_sessions', true)) {
// These are the only cache driven session backends (see Laravel's config/session.php) // These are the only cache driven session backends (see Laravel's config/session.php)
if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) { if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) {
if (app()->environment('production')) { throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions');
// We only throw this exception in prod to make configuration a little easier. Developers
// may have scope_sessions set to true while using different session drivers e.g. in tests.
// Previously we just silently ignored this, however since session scoping is of high importance
// in production, we make sure to notify the developer, by throwing an exception, that session
// scoping isn't happening as expected/configured due to an incompatible session driver.
throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session');
}
} else { } else {
// Scoping sessions using this bootstrapper implicitly adds the session store to $names // Scoping sessions using this bootstrapper implicitly adds the session store to $names
$names[] = $this->getSessionCacheStoreName(); $names[] = $this->getSessionCacheStoreName();

View file

@ -63,13 +63,17 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
$stores = $this->scopedStoreNames(); $stores = $this->scopedStoreNames();
foreach ($stores as $storeName) { foreach ($stores as $storeName) {
$this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection") ?? config('tenancy.database.central_connection');
$this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection"); $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection") ?? config('tenancy.database.central_connection');
$this->config->set("cache.stores.{$storeName}.connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.connection", 'tenant');
$this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant');
$this->cache->purge($storeName); /** @var DatabaseStore $store */
$store = $this->cache->store($storeName)->getStore();
$store->setConnection(DB::connection('tenant'));
$store->setLockConnection(DB::connection('tenant'));
} }
if (static::$adjustGlobalCacheManager) { if (static::$adjustGlobalCacheManager) {
@ -78,8 +82,8 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
// *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that // *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that
// (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution. // (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution.
$originalConnections = array_combine($stores, array_map(fn (string $storeName) => [ $originalConnections = array_combine($stores, array_map(fn (string $storeName) => [
'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), 'connection' => $this->originalConnections[$storeName],
'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), 'lockConnection' => $this->originalLockConnections[$storeName],
], $stores)); ], $stores));
TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) { TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) {
@ -100,7 +104,11 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
$this->config->set("cache.stores.{$storeName}.connection", $originalConnection); $this->config->set("cache.stores.{$storeName}.connection", $originalConnection);
$this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]); $this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]);
$this->cache->purge($storeName); /** @var DatabaseStore $store */
$store = $this->cache->store($storeName)->getStore();
$store->setConnection(DB::connection($this->originalConnections[$storeName]));
$store->setLockConnection(DB::connection($this->originalLockConnections[$storeName]));
} }
TenancyServiceProvider::$adjustCacheManagerUsing = null; TenancyServiceProvider::$adjustCacheManagerUsing = null;

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers; namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Session\FileSessionHandler; use Illuminate\Session\FileSessionHandler;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
@ -22,13 +21,6 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
) { ) {
$this->originalAssetUrl = $this->app['config']['app.asset_url']; $this->originalAssetUrl = $this->app['config']['app.asset_url'];
$this->originalStoragePath = $app->storagePath(); $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 public function bootstrap(Tenant $tenant): void
@ -78,6 +70,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
return; return;
} }
$path = $suffix
? $this->tenantStoragePath($suffix) . '/framework/cache'
: $this->originalStoragePath . '/framework/cache';
if (! is_dir($path)) {
// Create tenant framework/cache directory if it does not exist
mkdir($path, 0750, true);
}
if ($suffix === false) { if ($suffix === false) {
$this->app->useStoragePath($this->originalStoragePath); $this->app->useStoragePath($this->originalStoragePath);
} else { } else {
@ -98,16 +99,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
if ($suffix === false) { if ($suffix === false) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl; $this->app['config']['app.asset_url'] = $this->originalAssetUrl;
$this->app['url']->setAssetRoot($this->originalAssetUrl); $this->app['url']->useAssetOrigin($this->originalAssetUrl);
return; return;
} }
if ($this->originalAssetUrl) { if ($this->originalAssetUrl) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix"; $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 { } 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' => '']));
} }
} }
@ -124,7 +125,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
} }
} }
Storage::forgetDisk([...$tenantDisks, ...$scopedDisks]); Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks));
} }
protected function diskRoot(string $disk, Tenant|false $tenant): void protected function diskRoot(string $disk, Tenant|false $tenant): void
@ -222,7 +223,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
if (! is_dir($path)) { if (! is_dir($path)) {
// Create tenant framework/sessions directory if it does not exist // Create tenant framework/sessions directory if it does not exist
mkdir($path, 0755, true); mkdir($path, 0750, true);
} }
$this->app['config']['session.files'] = $path; $this->app['config']['session.files'] = $path;

View file

@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant;
class MailConfigBootstrapper implements TenancyBootstrapper class MailConfigBootstrapper implements TenancyBootstrapper
{ {
/** /**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature). * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper).
* *
* For example: * For example:
* [ * [

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator; use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
@ -78,6 +79,10 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
} }
} }
// Inherit scheme (http/https) from the original generator
$originalScheme = Str::before($this->originalUrlGenerator->formatScheme(), '://');
$newGenerator->forceScheme($originalScheme);
$newGenerator->defaults($defaultParameters); $newGenerator->defaults($defaultParameters);
$newGenerator->setSessionResolver(function () { $newGenerator->setSessionResolver(function () {

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Features\UserImpersonation;
/**
* Clears expired impersonation tokens.
*
* Tokens older than UserImpersonation::$ttl are considered expired.
*
* @see Stancl\Tenancy\Features\UserImpersonation
*/
class PurgeImpersonationTokens extends Command
{
protected $signature = 'tenants:purge-impersonation-tokens';
protected $description = 'Clear expired impersonation tokens.';
public function handle(): int
{
$this->components->info('Deleting expired impersonation tokens.');
$expirationDate = now()->subSeconds(UserImpersonation::$ttl);
$impersonationTokenModel = UserImpersonation::modelClass();
$deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate)
->delete();
$this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.');
return 0;
}
}

View file

@ -18,7 +18,7 @@ trait HasTenantOptions
{ {
return array_merge([ return array_merge([
['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null], ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null],
['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'], ['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'], // todo@pending should we also offer without-pending? if we add this, mention in docs
], parent::getOptions()); ], parent::getOptions());
} }

View file

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

View file

@ -10,13 +10,6 @@ use Illuminate\Database\Eloquent\Scope;
class PendingScope implements Scope class PendingScope implements Scope
{ {
/**
* All of the extensions to be added to the builder.
*
* @var string[]
*/
protected $extensions = ['WithPending', 'WithoutPending', 'OnlyPending'];
/** /**
* Apply the scope to a given Eloquent query builder. * Apply the scope to a given Eloquent query builder.
* *
@ -32,26 +25,21 @@ class PendingScope implements Scope
} }
/** /**
* Extend the query builder with the needed functions. * Add methods to the query builder.
* *
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
public function extend(Builder $builder) public function extend(Builder $builder): void
{ {
foreach ($this->extensions as $extension) { $this->addWithPending($builder);
$this->{"add{$extension}"}($builder); $this->addWithoutPending($builder);
} $this->addOnlyPending($builder);
} }
/** /**
* Add the with-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addWithPending(Builder $builder) protected function addWithPending(Builder $builder): void
{ {
$builder->macro('withPending', function (Builder $builder, $withPending = true) { $builder->macro('withPending', function (Builder $builder, $withPending = true) {
if (! $withPending) { if (! $withPending) {
@ -63,13 +51,9 @@ class PendingScope implements Scope
} }
/** /**
* Add the without-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addWithoutPending(Builder $builder) protected function addWithoutPending(Builder $builder): void
{ {
$builder->macro('withoutPending', function (Builder $builder) { $builder->macro('withoutPending', function (Builder $builder) {
$builder->withoutGlobalScope(static::class) $builder->withoutGlobalScope(static::class)
@ -81,13 +65,9 @@ class PendingScope implements Scope
} }
/** /**
* Add the only-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addOnlyPending(Builder $builder) protected function addOnlyPending(Builder $builder): void
{ {
$builder->macro('onlyPending', function (Builder $builder) { $builder->macro('onlyPending', function (Builder $builder) {
$builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since')); $builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since'));

View file

@ -44,12 +44,20 @@ class UserImpersonation implements Feature
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
abort_if($tokenExpired, 403); if ($tokenExpired) {
$token->delete();
abort(403);
}
$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn()); $tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
$currentTenantId = (string) tenant()->getTenantKey(); $currentTenantId = (string) tenant()->getTenantKey();
abort_unless($tokenTenantId === $currentTenantId, 403); if ($tokenTenantId !== $currentTenantId) {
$token->delete();
abort(403);
}
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember); Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);

View file

@ -4,18 +4,25 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\Contracts\TenantEvent;
/**
* Can be used to manually create framework directories in the tenant storage when storage_path() is scoped.
*
* Useful when using real-time facades which use the framework/cache directory.
*
* Generally not needed anymore as the directory is also created by the FilesystemTenancyBootstrapper.
*/
class CreateTenantStorage class CreateTenantStorage
{ {
public function handle(TenantCreated $event): void public function handle(TenantEvent $event): void
{ {
$storage_path = tenancy()->run($event->tenant, fn () => storage_path()); $storage_path = tenancy()->run($event->tenant, fn () => storage_path());
$cache_path = "$storage_path/framework/cache"; $cache_path = "$storage_path/framework/cache";
if (! is_dir($cache_path)) { if (! is_dir($cache_path)) {
// Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades) // Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades)
mkdir($cache_path, 0777, true); mkdir($cache_path, 0750, true);
} }
} }
} }

View file

@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Events\DeletingTenant; use Stancl\Tenancy\Events\Contracts\TenantEvent;
class DeleteTenantStorage class DeleteTenantStorage
{ {
public function handle(DeletingTenant $event): void public function handle(TenantEvent $event): void
{ {
$path = tenancy()->run($event->tenant, fn () => storage_path()); $path = tenancy()->run($event->tenant, fn () => storage_path());

View file

@ -13,7 +13,7 @@ class CentralResourceNotAvailableInPivotException extends Exception
parent::__construct( parent::__construct(
'Central resource is not accessible in pivot model. 'Central resource is not accessible in pivot model.
To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching).
To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' To make this work both ways, you can make your pivot implement PivotWithCentralResource and return the related model in getCentralResourceClass() or extend MorphPivot.'
); );
} }
} }

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,40 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Listeners\QueueableListener;
/**
* Cleans 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 with onDelete('cascade').
*/
class DeleteAllTenantMappings extends QueueableListener
{
public static bool $shouldQueue = false;
/**
* Pivot tables to clean up after a tenant is deleted, in the
* ['table_name' => 'tenant_key_column'] format.
*
* Since we cannot automatically detect which pivot tables
* are being used, they have to be specified here manually.
*
* The default value follows the polymorphic table used by default.
*/
public static array $pivotTables = ['tenant_resources' => 'tenant_id'];
public function handle(TenantDeleted $event): void
{
foreach (static::$pivotTables as $table => $tenantKeyColumn) {
DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->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) {
// If detach() is called with null -- if $event->tenant is null -- this means a central resource was deleted and detaches all tenants.
// If detach() is called with a specific tenant, it means the resource was deleted in that tenant, and we only delete that single mapping.
$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; namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener
tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) {
$this->deleteSyncedResource($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

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
interface PivotWithCentralResource
{
/** @return class-string<\Illuminate\Database\Eloquent\Model&Syncable> */
public function getCentralResourceClass(): string;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
interface PivotWithRelation
{
/**
* E.g. return $this->users()->getModel().
*/
public function getRelatedModel(): Model;
}

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
@ -19,37 +20,34 @@ trait ResourceSyncing
{ {
public static function bootResourceSyncing(): void public static function bootResourceSyncing(): void
{ {
static::saved(function (Syncable&Model $model) { static::saved(static function (Syncable&Model $model) {
if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) {
$model->triggerSyncEvent(); $model->triggerSyncEvent();
} }
}); });
static::deleting(function (Syncable&Model $model) { static::deleted(static function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerDeleteEvent(); $model->triggerDeleteEvent();
} }
}); });
static::creating(function (Syncable&Model $model) { static::creating(static function (Syncable&Model $model) {
if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) {
$model->setAttribute( $model->generateGlobalIdentifierKey();
$model->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($model)
);
} }
}); });
if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) {
static::forceDeleting(function (Syncable&Model $model) { static::forceDeleting(static function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model->shouldSync()) {
$model->triggerDeleteEvent(true); $model->triggerDeleteEvent(true);
} }
}); });
static::restoring(function (Syncable&Model $model) { static::restoring(static function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) { if ($model instanceof SyncMaster && $model->shouldSync()) {
$model->triggerRestoredEvent(); $model->triggerRestoreEvent();
} }
}); });
} }
@ -67,9 +65,11 @@ trait ResourceSyncing
/** @var SyncMaster&Model $this */ /** @var SyncMaster&Model $this */
event(new SyncMasterDeleted($this, $forceDelete)); event(new SyncMasterDeleted($this, $forceDelete));
} }
event(new SyncedResourceDeleted($this, tenant(), $forceDelete));
} }
public function triggerRestoredEvent(): void public function triggerRestoreEvent(): void
{ {
if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) { if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) {
/** @var SyncMaster&Model $this */ /** @var SyncMaster&Model $this */
@ -116,8 +116,18 @@ trait ResourceSyncing
return 'global_id'; return 'global_id';
} }
public function getGlobalIdentifierKey(): string public function getGlobalIdentifierKey(): string|int
{ {
return $this->getAttribute($this->getGlobalIdentifierKeyName()); return $this->getAttribute($this->getGlobalIdentifierKeyName());
} }
protected function generateGlobalIdentifierKey(): void
{
if (! app()->bound(UniqueIdentifierGenerator::class)) return;
$this->setAttribute(
$this->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($this),
);
}
} }

View file

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

View file

@ -16,6 +16,8 @@ interface Syncable
public function triggerSyncEvent(): void; 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). * 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

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Database\Eloquent\Relations\MorphPivot;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Relations\Relation;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
@ -20,14 +21,14 @@ trait TriggerSyncingEvents
{ {
public static function bootTriggerSyncingEvents(): void public static function bootTriggerSyncingEvents(): void
{ {
static::saving(function (self $pivot) { static::saving(static function (self $pivot) {
// Try getting the central resource to see if it is available // Try getting the central resource to see if it is available
// If it is not available, throw an exception to interrupt the saving process // If it is not available, throw an exception to interrupt the saving process
// And prevent creating a pivot record without a central resource // And prevent creating a pivot record without a central resource
$pivot->getCentralResourceAndTenant(); $pivot->getCentralResourceAndTenant();
}); });
static::saved(function (self $pivot) { static::saved(static function (self $pivot) {
/** /**
* @var static&Pivot $pivot * @var static&Pivot $pivot
* @var SyncMaster|null $centralResource * @var SyncMaster|null $centralResource
@ -40,7 +41,7 @@ trait TriggerSyncingEvents
} }
}); });
static::deleting(function (self $pivot) { static::deleting(static function (self $pivot) {
/** /**
* @var static&Pivot $pivot * @var static&Pivot $pivot
* @var SyncMaster|null $centralResource * @var SyncMaster|null $centralResource
@ -79,13 +80,13 @@ trait TriggerSyncingEvents
*/ */
protected function getResourceClass(): string protected function getResourceClass(): string
{ {
/** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithRelation)) $this */ /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithCentralResource)) $this */
if ($this instanceof PivotWithRelation) { if ($this instanceof PivotWithCentralResource) {
return $this->getRelatedModel()::class; return $this->getCentralResourceClass();
} }
if ($this instanceof MorphPivot) { if ($this instanceof MorphPivot) {
return $this->morphClass; return Relation::getMorphedModel($this->morphClass) ?? $this->morphClass;
} }
throw new CentralResourceNotAvailableInPivotException; throw new CentralResourceNotAvailableInPivotException;

View file

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

View file

@ -9,7 +9,7 @@ use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/** /**
* Generates a UUID for the tenant key. * Generates a ULID for the tenant key.
*/ */
class ULIDGenerator implements UniqueIdentifierGenerator class ULIDGenerator implements UniqueIdentifierGenerator
{ {

View file

@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/** /**
* Generates a UUID for the tenant key. * Generates a UUIDv4 for the tenant key.
*/ */
class UUIDGenerator implements UniqueIdentifierGenerator class UUIDGenerator implements UniqueIdentifierGenerator
{ {

View file

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

View file

@ -201,6 +201,27 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen
expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($tenantStoragePath))->toBeFalse();
}); });
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath
]);
$centralStoragePath = storage_path();
tenancy()->initialize($tenant = Tenant::create());
if ($suffixStoragePath) {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . "/tenant{$tenant->id}/framework/cache");
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeTrue();
} else {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . '/framework/cache');
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse();
}
})->with([true, false]);
test('scoped disks are scoped per tenant', function () { test('scoped disks are scoped per tenant', function () {
config([ config([
'tenancy.bootstrappers' => [ 'tenancy.bootstrappers' => [
@ -215,16 +236,24 @@ test('scoped disks are scoped per tenant', function () {
$tenant = Tenant::create(); $tenant = Tenant::create();
$storagePath = storage_path() . "/tenant{$tenant->id}"; Storage::disk('scoped_disk')->put('foo.txt', 'central');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
// Resolve scoped_disk before initializing tenancy expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central');
Storage::disk('scoped_disk');
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
Storage::disk('scoped_disk')->put('foo.txt', 'foo text'); expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null);
Storage::disk('scoped_disk')->put('foo.txt', 'tenant');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant');
tenancy()->end(); tenancy()->end();
expect(File::exists($storagePath . '/app/public/scoped_disk_prefix/foo.txt'))->toBeTrue(); expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
Storage::disk('scoped_disk')->put('foo.txt', 'central2');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2');
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
}); });

View file

@ -18,7 +18,6 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
@ -80,6 +79,44 @@ test('tenancy url generator can prefix route names passed to the route helper',
expect(route('home'))->toBe('http://localhost/central/home'); expect(route('home'))->toBe('http://localhost/central/home');
}); });
test('tenancy url generator inherits scheme from original url generator', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/home', fn () => '')->name('home');
// No scheme forced, default is HTTP
expect(app('url')->formatScheme())->toBe('http://');
$tenant = Tenant::create();
// Force the original URL generator to use HTTPS
app('url')->forceScheme('https');
// Original generator uses HTTPS
expect(app('url')->formatScheme())->toBe('https://');
// Check that TenancyUrlGenerator inherits the HTTPS scheme
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('https://'); // Should inherit HTTPS
expect(route('home'))->toBe('https://localhost/home');
tenancy()->end();
// After ending tenancy, the original generator should still have the original scheme (HTTPS)
expect(route('home'))->toBe('https://localhost/home');
// Use HTTP scheme
app('url')->forceScheme('http');
expect(app('url')->formatScheme())->toBe('http://');
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('http://'); // Should inherit scheme (HTTP)
expect(route('home'))->toBe('http://localhost/home');
tenancy()->end();
expect(route('home'))->toBe('http://localhost/home');
});
test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);

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'); $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
} }
})->with([true, false]); })->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 class CentralUser extends Model implements SyncMaster
{ {
use ResourceSyncing, CentralConnection; use ResourceSyncing, CentralConnection;
protected $guarded = []; protected $guarded = [];
public $timestamps = false; public $timestamps = false;

View file

@ -4,20 +4,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing; namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\ResourceSyncing\PivotWithRelation;
use Stancl\Tenancy\ResourceSyncing\TenantPivot; use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomPivot extends TenantPivot implements PivotWithRelation class CustomPivot extends TenantPivot implements PivotWithCentralResource
{ {
public function users(): BelongsToMany public function getCentralResourceClass(): string
{ {
return $this->belongsToMany(CentralUser::class); return CentralUser::class;
}
public function getRelatedModel(): Model
{
return $this->users()->getModel();
} }
} }

View file

@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration
$table->string('global_user_id'); $table->string('global_user_id');
$table->unique(['tenant_id', '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\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Tenancy;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class, 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) { test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls; CreateUserWithRLSPolicies::$forceRls = $forceRls;

View file

@ -46,6 +46,13 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest; 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;
use Illuminate\Database\Eloquent\Relations\Relation;
beforeEach(function () { beforeEach(function () {
config(['tenancy.bootstrappers' => [ config(['tenancy.bootstrappers' => [
@ -69,6 +76,7 @@ beforeEach(function () {
CreateTenantResource::$shouldQueue = false; CreateTenantResource::$shouldQueue = false;
DeleteResourceInTenant::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
// Reset global scopes on models (should happen automatically but to make this more explicit) // Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels(); Model::clearBootedModels();
@ -92,6 +100,7 @@ beforeEach(function () {
CentralUser::$creationAttributes = $creationAttributes; CentralUser::$creationAttributes = $creationAttributes;
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class);
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
@ -255,7 +264,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant
expect(TenantUser::all())->toHaveCount(0); expect(TenantUser::all())->toHaveCount(0);
}); });
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($createCentralUser()); $tenant->customPivotUsers()->attach($createCentralUser());
$createCentralUser()->tenants()->attach($tenant); $createCentralUser()->tenants()->attach($tenant);
@ -279,7 +288,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
migrateUsersTableForTenants(); migrateUsersTableForTenants();
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($centralUser); $tenant->customPivotUsers()->attach($centralUser);
} else { } else {
$centralUser->tenants()->attach($tenant); $centralUser->tenants()->attach($tenant);
@ -290,7 +299,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
}); });
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUser); $tenant->customPivotUsers()->detach($centralUser);
} else { } else {
$centralUser->tenants()->detach($tenant); $centralUser->tenants()->detach($tenant);
@ -325,7 +334,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
}); });
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes);
} else { } else {
$centralUserWithSoftDeletes->tenants()->detach($tenant); $centralUserWithSoftDeletes->tenants()->detach($tenant);
@ -890,7 +899,54 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $
'basic pivot' => false, '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', 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(); [$tenant] = createTenantsAndRunMigrations();
$syncMaster = CentralUser::create([ $syncMaster = CentralUser::create([
@ -903,10 +959,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo
$syncMaster->tenants()->attach($tenant); $syncMaster->tenants()->attach($tenant);
$tenant->delete(); expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
// Deleting tenant deletes its pivot records $tenant->run(function () {
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant resource deletes its pivot record
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
// The same works with forceDelete
addExtraColumns(true);
$syncMaster = CentralUserWithSoftDeletes::create([
'global_id' => 'force_cascade_user',
'name' => 'Central user',
'email' => 'central2@localhost',
'password' => 'password',
'role' => 'force_cascade_user',
'foo' => 'bar',
]);
$syncMaster->tenants()->attach($tenant);
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
$tenant->run(function () {
TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete();
});
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->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
DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column'];
expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist");
}); });
test('trashed resources are synced correctly', function () { test('trashed resources are synced correctly', function () {
@ -1265,6 +1365,60 @@ test('global scopes on syncable models can break resource syncing', function ()
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
}); });
test('attach and detach events are handled correctly when using morph maps', function() {
config(['tenancy.models.tenant' => MorphTenant::class]);
[$tenant] = createTenantsAndRunMigrations();
migrateCompaniesTableForTenants();
Relation::morphMap([
'users' => BaseCentralUser::class,
'companies' => CentralCompany::class,
]);
$centralUser = BaseCentralUser::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$centralCompany = CentralCompany::create([
'global_id' => 'company',
'name' => 'Central company',
'email' => 'company@localhost',
]);
$tenant->users()->attach($centralUser);
$tenant->companies()->attach($centralCompany);
// Assert all tenant_resources mappings actually use the configured morph map
expect(DB::table('tenant_resources')->count())
->toBe(DB::table('tenant_resources')->whereIn('tenant_resources_type', ['users', 'companies'])->count());
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->not()->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->not()->toBeNull();
tenancy()->end();
$tenant->users()->detach($centralUser);
$tenant->companies()->detach($centralCompany);
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->toBeNull();
});
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. * Create two tenants and run migrations for those tenants.
* *

View file

@ -56,6 +56,7 @@ test('file sessions are separated', function (bool $scopeSessions) {
if ($scopeSessions) { if ($scopeSessions) {
expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions'));
expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue();
} else { } else {
expect($sessionPath())->toBe(storage_path('framework/sessions')); expect($sessionPath())->toBe(storage_path('framework/sessions'));
} }

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
@ -94,6 +95,20 @@ test('ulid ids are supported', function () {
expect($tenant2->id > $tenant1->id)->toBeTrue(); expect($tenant2->id > $tenant1->id)->toBeTrue();
}); });
test('uuidv7 ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, UUIDv7Generator::class);
$tenant1 = Tenant::create();
expect($tenant1->id)->toBeString();
expect(strlen($tenant1->id))->toBe(36);
$tenant2 = Tenant::create();
expect($tenant2->id)->toBeString();
expect(strlen($tenant2->id))->toBe(36);
expect($tenant2->id > $tenant1->id)->toBeTrue();
});
test('hex ids are supported', function () { test('hex ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class);

View file

@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Symfony\Component\HttpKernel\Exception\HttpException;
beforeEach(function () { beforeEach(function () {
pest()->artisan('migrate', [ pest()->artisan('migrate', [
@ -294,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function (
->toBeInstanceOf(ImpersonationToken::class); ->toBeInstanceOf(ImpersonationToken::class);
}); });
test('expired tokens are cleaned up before aborting', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make the token expired
$token->update([
'created_at' => Carbon::now()->subSeconds(100),
]);
expect(ImpersonationToken::find($token->token))->not()->toBeNull();
tenancy()->initialize($tenant);
// Try to use the expired token - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('tokens are cleaned up when in wrong tenant context before aborting', function () {
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
migrateTenants();
$user = $tenant1->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant1, $user->id, '/dashboard');
expect(ImpersonationToken::find($token->token))->not->toBeNull();
tenancy()->initialize($tenant2);
// Try to use the token in wrong tenant context - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('expired impersonation tokens can be cleaned up using a command', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
// Create tokens
$oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make two of the tokens expired by updating their created_at
$oldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
$anotherOldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
// All tokens exist
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull();
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('2 expired impersonation tokens deleted');
// The expired tokens were deleted
expect(ImpersonationToken::find($oldToken->token))->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull();
// The active token still exists
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
// Update the active token to make it expired according to the default ttl (60s)
$activeToken->update([
'created_at' => Carbon::now()->subSeconds(70),
]);
// With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s)
UserImpersonation::$ttl = 80;
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('0 expired impersonation tokens deleted');
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
});
function migrateTenants() function migrateTenants()
{ {
pest()->artisan('tenants:migrate')->assertExitCode(0); pest()->artisan('tenants:migrate')->assertExitCode(0);