From b967d1647aa3f4b6dbfd60559387809b67c4324d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 15:45:48 +0100 Subject: [PATCH 1/4] Add UUIDv7Generator Also correct docblock for ULIDGenerator and add missing @see annotations in the config file. --- assets/config.php | 2 ++ .../ULIDGenerator.php | 2 +- .../UUIDGenerator.php | 2 +- .../UUIDv7Generator.php | 20 +++++++++++++++++++ tests/TenantModelTest.php | 15 ++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/UniqueIdentifierGenerators/UUIDv7Generator.php diff --git a/assets/config.php b/assets/config.php index ce74d3bf..76441036 100644 --- a/assets/config.php +++ b/assets/config.php @@ -48,6 +48,8 @@ return [ * 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\ULIDGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator diff --git a/src/UniqueIdentifierGenerators/ULIDGenerator.php b/src/UniqueIdentifierGenerators/ULIDGenerator.php index 17b62898..d099c824 100644 --- a/src/UniqueIdentifierGenerators/ULIDGenerator.php +++ b/src/UniqueIdentifierGenerators/ULIDGenerator.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a ULID for the tenant key. */ class ULIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDGenerator.php b/src/UniqueIdentifierGenerators/UUIDGenerator.php index f8bf4b9c..a537b666 100644 --- a/src/UniqueIdentifierGenerators/UUIDGenerator.php +++ b/src/UniqueIdentifierGenerators/UUIDGenerator.php @@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a UUIDv4 for the tenant key. */ class UUIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDv7Generator.php b/src/UniqueIdentifierGenerators/UUIDv7Generator.php new file mode 100644 index 00000000..274b17b8 --- /dev/null +++ b/src/UniqueIdentifierGenerators/UUIDv7Generator.php @@ -0,0 +1,20 @@ +toString(); + } +} diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index 4c6e77e1..8ee2ae78 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; +use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator; use function Stancl\Tenancy\Tests\pest; @@ -94,6 +95,20 @@ test('ulid ids are supported', function () { 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 () { app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); From 0ef4dfd23051bd1cc8f06bccfcba05e2fdc8881b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 15:47:15 +0100 Subject: [PATCH 2/4] DB cache bootstrapper: setConnection() instead of purge() (#1408) By purging stores, we "detach" existing cache stores from the CacheManager, making them impossible to adjust in the future. We also unnecessarily recreate them on every tenancy bootstrap/revert. A simpler case where this causes problems is defining a RateLimiter in a service provider. That injects a single cache store into the rate limiter singleton, which then becomes a completely independent object after tenancy is initialized due to the purge. This in turn means the central and tenant contexts share the rate limiter cache instead of using separate caches as one would expect. --- .../DatabaseCacheBootstrapper.php | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php index ae547471..0e41849f 100644 --- a/src/Bootstrappers/DatabaseCacheBootstrapper.php +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -63,13 +63,17 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper $stores = $this->scopedStoreNames(); foreach ($stores as $storeName) { - $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); - $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_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") ?? config('tenancy.database.central_connection'); $this->config->set("cache.stores.{$storeName}.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) { @@ -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 // (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) => [ - 'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), - 'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), + 'connection' => $this->originalConnections[$storeName], + 'lockConnection' => $this->originalLockConnections[$storeName], ], $stores)); 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}.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; From cab8ecebeced03306d4121eb7a397d37310278dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 4 Nov 2025 21:16:39 +0100 Subject: [PATCH 3/4] Create tenant storage directories in FilesystemTenancyBootstrapper (#1410) This is because the CreateTenantStorage listener only runs when a tenant is created, but in multi-server setups the directory may need to be created each time a tenant is *used*, not just created. Also changed the listeners to use TenantEvent instead of specific events, to make it possible to use them with other events, such as TenancyBootstrapped. Also update permission bits in a few mkdir() calls to better scope data to the current OS user. Also fix a typo in CacheTenancyBootstrapper (exception message). --- .../CacheTenancyBootstrapper.php | 2 +- .../FilesystemTenancyBootstrapper.php | 11 +++++++++- src/Listeners/CreateTenantStorage.php | 13 +++++++++--- src/Listeners/DeleteTenantStorage.php | 4 ++-- .../FilesystemTenancyBootstrapperTest.php | 21 +++++++++++++++++++ tests/SessionSeparationTest.php | 1 + 6 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 20e09816..9d87e19a 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -108,7 +108,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper // 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'); + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions'); } } else { // Scoping sessions using this bootstrapper implicitly adds the session store to $names diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index d5088c5c..2c2d9ec9 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -78,6 +78,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper 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) { $this->app->useStoragePath($this->originalStoragePath); } else { @@ -211,7 +220,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if (! is_dir($path)) { // Create tenant framework/sessions directory if it does not exist - mkdir($path, 0755, true); + mkdir($path, 0750, true); } $this->app['config']['session.files'] = $path; diff --git a/src/Listeners/CreateTenantStorage.php b/src/Listeners/CreateTenantStorage.php index 73da89fc..3bebb731 100644 --- a/src/Listeners/CreateTenantStorage.php +++ b/src/Listeners/CreateTenantStorage.php @@ -4,18 +4,25 @@ declare(strict_types=1); 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 { - public function handle(TenantCreated $event): void + public function handle(TenantEvent $event): void { $storage_path = tenancy()->run($event->tenant, fn () => storage_path()); $cache_path = "$storage_path/framework/cache"; if (! is_dir($cache_path)) { // 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); } } } diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php index 25adc4f4..ec360073 100644 --- a/src/Listeners/DeleteTenantStorage.php +++ b/src/Listeners/DeleteTenantStorage.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; use Illuminate\Support\Facades\File; -use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Events\Contracts\TenantEvent; class DeleteTenantStorage { - public function handle(DeletingTenant $event): void + public function handle(TenantEvent $event): void { $path = tenancy()->run($event->tenant, fn () => storage_path()); diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 857e0eac..706a7882 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -200,3 +200,24 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen 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]); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 02b018d1..d699bc61 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -56,6 +56,7 @@ test('file sessions are separated', function (bool $scopeSessions) { if ($scopeSessions) { expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); + expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue(); } else { expect($sessionPath())->toBe(storage_path('framework/sessions')); } From 510358b9beef8a7be8405585ee9a617ac5bd7a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 5 Nov 2025 14:53:07 +0100 Subject: [PATCH 4/4] Config: scope_sessions = true only with supported drivers, always throw With the previous implementation, many users would use the default config that enables scope_sessions. They would then deploy the app to production and get the exception there since they use the `database` session driver which is scoped by a different mechanism. The idea behind throwing the exception only in prod was to make it easy to use different setups locally without getting annoying exceptions, while notifying users that a security feature they enabled isn't running in production. However, a better way of doing this is to just throw the exception consistently in all setups and use a sane default for enabling the scope_sessions setting based on the SESSION_DRIVER env var. Users are always encouraged to read the session scoping docs to make sure their session scoping configuration makes sense for their specific setup, but this is a good balance for providing solid security out of the box for most setups without requiring users to configure things manually. --- assets/config.php | 2 +- src/Bootstrappers/CacheTenancyBootstrapper.php | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/assets/config.php b/assets/config.php index 76441036..f15a843a 100644 --- a/assets/config.php +++ b/assets/config.php @@ -313,7 +313,7 @@ return [ * * 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. ], diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 9d87e19a..97bd7d24 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -102,14 +102,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper if ($this->config->get('tenancy.cache.scope_sessions', true)) { // 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 (app()->environment('production')) { - // 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_sessions'); - } + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions'); } else { // Scoping sessions using this bootstrapper implicitly adds the session store to $names $names[] = $this->getSessionCacheStoreName();