diff --git a/composer.json b/composer.json index db60d4d..29192ba 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,9 @@ "phpstan" ], "test" : [ + "@clear", + "@prepare", + "@build", "@php vendor/bin/phpunit" ] }, diff --git a/resources/config/sprout.php b/resources/config/sprout.php index fd8e07e..732ccc3 100644 --- a/resources/config/sprout.php +++ b/resources/config/sprout.php @@ -20,28 +20,48 @@ /* |-------------------------------------------------------------------------- - | Service Overrides + | The event listeners used to bootstrap a tenancy |-------------------------------------------------------------------------- | - | This value sets which core Laravel services Sprout should override. - | - | Setting a service to false will disable its tenant-specific - | configuration/settings, and leave them using the default. + | This value contains all the listeners that should be run for the + | \Sprout\Events\CurrentTenantChanged event to bootstrap a tenancy. | */ - 'services' => [ - // This will enable the 'sprout' driver for the filesystem disks, - // allowing for the creation of tenant scoped disks. - 'storage' => true, + 'bootstrappers' => [ + // Calls the setup method on the current identity resolver + \Sprout\Listeners\PerformIdentityResolverSetup::class, + // Performs any clean-up from the previous tenancy + \Sprout\Listeners\CleanupServiceOverrides::class, + // Sets up service overrides for the current tenancy + \Sprout\Listeners\SetupServiceOverrides::class, + // Set the current tenant within the Laravel context + \Sprout\Listeners\SetCurrentTenantContext::class, + ], - // This will enable the overwriting of the default settings for cookies. - // Each identity resolver may have effect different settings. - 'cookies' => true, + /* + |-------------------------------------------------------------------------- + | Service Overrides + |-------------------------------------------------------------------------- + | + | This is an array of service override classes. + | These classes will be instantiated and automatically run when relevant. + | + */ - // This will enable the overwriting of the default settings for sessions. - // Each identity resolver may have effect different settings. - 'sessions' => true, + 'services' => [ + // This will override the storage by introducing a 'sprout' driver + // that wraps any other storage drive in a tenant resource subdirectory. + \Sprout\Overrides\StorageOverride::class, + // This will override the cache by introducing a 'sprout' driver + // that adds a prefix to cache stores for the current tenant. + \Sprout\Overrides\CacheOverride::class, + // This will override the cookie settings so that all created cookies + // are specific to the tenant. + \Sprout\Overrides\CookieOverride::class, + // This will override the session by introducing a 'sprout' driver + // that wraps any other session store. + \Sprout\Overrides\SessionOverride::class, ], ]; diff --git a/src/Contracts/BootableServiceOverride.php b/src/Contracts/BootableServiceOverride.php new file mode 100644 index 0000000..acd0f0b --- /dev/null +++ b/src/Contracts/BootableServiceOverride.php @@ -0,0 +1,29 @@ + $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function setup(Tenancy $tenancy, Tenant $tenant): void; + + /** + * Clean up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when the current tenant is unset, either to be replaced + * by another tenant, or none. + * + * It will be called before {@see self::setup()}, but only if the previous + * tenant was not null. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function cleanup(Tenancy $tenancy, Tenant $tenant): void; +} diff --git a/src/Database/Eloquent/Observers/BelongsToManyTenantsObserver.php b/src/Database/Eloquent/Observers/BelongsToManyTenantsObserver.php index 2f58828..c3f07a7 100644 --- a/src/Database/Eloquent/Observers/BelongsToManyTenantsObserver.php +++ b/src/Database/Eloquent/Observers/BelongsToManyTenantsObserver.php @@ -20,9 +20,9 @@ class BelongsToManyTenantsObserver /** * Check if a model already has a tenant set * - * @param \Illuminate\Database\Eloquent\Model $model - * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation - * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param \Sprout\Contracts\Tenancy $tenancy * * @return bool */ @@ -44,9 +44,9 @@ private function doesModelAlreadyHaveATenant(Model $model, BelongsToMany $relati /** * Check if a model belongs to a different tenant * - * @param \Illuminate\Database\Eloquent\Model $model - * @param \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant - * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation * * @return bool */ @@ -67,7 +67,8 @@ private function isTenantMismatched(Model $model, Tenant&Model $tenant, BelongsT * * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToManyTenants $model * @param \Sprout\Contracts\Tenancy $tenancy - * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param bool $succeedOnMatch * * @return bool * @@ -137,7 +138,7 @@ private function passesInitialChecks(Model $model, Tenancy $tenancy, BelongsToMa public function created(Model $model): void { /** - * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation * @phpstan-ignore-next-line */ $relation = $model->getTenantRelation(); @@ -180,7 +181,7 @@ public function created(Model $model): void public function retrieved(Model $model): void { /** - * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation * @phpstan-ignore-next-line */ $relation = $model->getTenantRelation(); @@ -208,12 +209,12 @@ public function retrieved(Model $model): void } /** - * @param \Illuminate\Database\Eloquent\Model $model - * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation - * @param \Sprout\Contracts\Tenant $tenant + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param \Sprout\Contracts\Tenant $tenant * - * @phpstan-param ChildModel $model - * @phpstan-param TenantModel $tenant + * @phpstan-param ChildModel $model + * @phpstan-param TenantModel $tenant * * @return void */ diff --git a/src/Http/Resolvers/PathIdentityResolver.php b/src/Http/Resolvers/PathIdentityResolver.php index 8a5bafd..c6c8a1b 100644 --- a/src/Http/Resolvers/PathIdentityResolver.php +++ b/src/Http/Resolvers/PathIdentityResolver.php @@ -13,13 +13,15 @@ use Sprout\Contracts\Tenant; use Sprout\Exceptions\TenantMissing; use Sprout\Http\Middleware\TenantRoutes; +use Sprout\Overrides\CookieOverride; +use Sprout\Overrides\SessionOverride; use Sprout\Support\BaseIdentityResolver; -use Sprout\Support\CookieHelper; -use function Sprout\sprout; final class PathIdentityResolver extends BaseIdentityResolver implements IdentityResolverUsesParameters { - use FindsIdentityInRouteParameter; + use FindsIdentityInRouteParameter { + setup as parameterSetup; + } private int $segment = 1; @@ -147,10 +149,15 @@ public function getTenantRoutePrefix(Tenancy $tenancy): string */ public function setup(Tenancy $tenancy, ?Tenant $tenant): void { + // Call the parent implementation in case there's something there parent::setup($tenancy, $tenant); - if ($tenant !== null && sprout()->config('services.cookies', false) === true) { - CookieHelper::setDefaults(path: $this->getTenantRoutePrefix($tenancy)); + // Call the trait setup so that parameter has a default value + $this->parameterSetup($tenancy, $tenant); + + if ($tenant !== null) { + CookieOverride::setPath($this->getTenantRoutePrefix($tenancy)); + SessionOverride::setPath($this->getTenantRoutePrefix($tenancy)); } } } diff --git a/src/Http/Resolvers/SessionIdentityResolver.php b/src/Http/Resolvers/SessionIdentityResolver.php index 1818059..fdb5175 100644 --- a/src/Http/Resolvers/SessionIdentityResolver.php +++ b/src/Http/Resolvers/SessionIdentityResolver.php @@ -7,10 +7,13 @@ use Illuminate\Http\Request; use Illuminate\Routing\Router; use Illuminate\Routing\RouteRegistrar; +use RuntimeException; use Sprout\Contracts\Tenancy; use Sprout\Http\Middleware\TenantRoutes; +use Sprout\Overrides\SessionOverride; use Sprout\Support\BaseIdentityResolver; use Sprout\Support\ResolutionHook; +use function Sprout\sprout; final class SessionIdentityResolver extends BaseIdentityResolver { @@ -60,6 +63,10 @@ public function getRequestSessionName(Tenancy $tenancy): string */ public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string { + if (sprout()->hasOverride(SessionOverride::class)) { + throw new RuntimeException('Cannot use the session resolver for tenancy [' . $tenancy->getName() . '] and the session override'); + } + /** * This is unfortunately here because of the ludicrous return type * diff --git a/src/Http/Resolvers/SubdomainIdentityResolver.php b/src/Http/Resolvers/SubdomainIdentityResolver.php index 41590cf..f42da2f 100644 --- a/src/Http/Resolvers/SubdomainIdentityResolver.php +++ b/src/Http/Resolvers/SubdomainIdentityResolver.php @@ -13,13 +13,15 @@ use Sprout\Contracts\Tenant; use Sprout\Exceptions\TenantMissing; use Sprout\Http\Middleware\TenantRoutes; +use Sprout\Overrides\CookieOverride; +use Sprout\Overrides\SessionOverride; use Sprout\Support\BaseIdentityResolver; -use Sprout\Support\CookieHelper; -use function Sprout\sprout; final class SubdomainIdentityResolver extends BaseIdentityResolver implements IdentityResolverUsesParameters { - use FindsIdentityInRouteParameter; + use FindsIdentityInRouteParameter { + setup as parameterSetup; + } private string $domain; @@ -139,10 +141,15 @@ public function getTenantRouteDomain(Tenancy $tenancy): string */ public function setup(Tenancy $tenancy, ?Tenant $tenant): void { + // Call the parent implementation in case there's something there parent::setup($tenancy, $tenant); - if ($tenant !== null && sprout()->config('services.cookies', false) === true) { - CookieHelper::setDefaults(domain: $this->getTenantRouteDomain($tenancy)); + // Call the trait setup so that parameter has a default value + $this->parameterSetup($tenancy, $tenant); + + if ($tenant !== null) { + CookieOverride::setDomain($this->getTenantRouteDomain($tenancy)); + SessionOverride::setDomain($this->getTenantRouteDomain($tenancy)); } } } diff --git a/src/Listeners/CleanupLaravelServices.php b/src/Listeners/CleanupLaravelServices.php deleted file mode 100644 index e470e32..0000000 --- a/src/Listeners/CleanupLaravelServices.php +++ /dev/null @@ -1,51 +0,0 @@ -filesystemManager = $filesystemManager; - } - - /** - * @template TenantClass of \Sprout\Contracts\Tenant - * - * @param \Sprout\Events\CurrentTenantChanged $event - * - * @return void - */ - public function handle(CurrentTenantChanged $event): void - { - $this->purgeFilesystemDisks(); - } - - private function purgeFilesystemDisks(): void - { - // If we're not overriding the storage service we can exit early - if (config('sprout.services.storage', false) === false) { - return; - } - - /** @var array> $diskConfig */ - $diskConfig = config('filesystems.disks', []); - - // If any of the disks have the 'sprout' driver we need to purge them, - // if they exist, so we don't end up leaking tenant information - foreach ($diskConfig as $disk => $config) { - if (($config['driver'] ?? null) === 'sprout') { - $this->filesystemManager->forgetDisk($disk); - } - } - } -} diff --git a/src/Listeners/CleanupServiceOverrides.php b/src/Listeners/CleanupServiceOverrides.php new file mode 100644 index 0000000..a43ac84 --- /dev/null +++ b/src/Listeners/CleanupServiceOverrides.php @@ -0,0 +1,39 @@ +sprout = $sprout; + } + + /** + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Events\CurrentTenantChanged $event + * + * @return void + */ + public function handle(CurrentTenantChanged $event): void + { + // If there's no previous tenant, we aren't interested + if ($event->previous === null) { + return; + } + + foreach ($this->sprout->getOverrides() as $override) { + $override->cleanup($event->tenancy, $event->previous); + } + } +} diff --git a/src/Listeners/SetupServiceOverrides.php b/src/Listeners/SetupServiceOverrides.php new file mode 100644 index 0000000..50ca93d --- /dev/null +++ b/src/Listeners/SetupServiceOverrides.php @@ -0,0 +1,39 @@ +sprout = $sprout; + } + + /** + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Events\CurrentTenantChanged $event + * + * @return void + */ + public function handle(CurrentTenantChanged $event): void + { + // If there's no current tenant, we aren't interested + if ($event->current === null) { + return; + } + + foreach ($this->sprout->getOverrides() as $override) { + $override->setup($event->tenancy, $event->current); + } + } +} diff --git a/src/Overrides/CacheOverride.php b/src/Overrides/CacheOverride.php new file mode 100644 index 0000000..3c80eea --- /dev/null +++ b/src/Overrides/CacheOverride.php @@ -0,0 +1,206 @@ + + */ + private static array $purgableStores = []; + + /** + * Boot a service override + * + * This method should perform any initial steps required for the service + * override that take place during the booting of the framework. + * + * @param \Illuminate\Contracts\Foundation\Application $app + * @param \Sprout\Sprout $sprout + * + * @return void + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function boot(Application $app, Sprout $sprout): void + { + $cacheManager = app(CacheManager::class); + + $cacheManager->extend('sprout', + /** + * @param array $config + * + * @throws \Sprout\Exceptions\TenantMissing + */ + function (Application $app, array $config) use ($sprout, $cacheManager) { + $tenancy = $sprout->tenancies()->get($config['tenancy'] ?? null); + + // If there's no tenant, error out + if (! $tenancy->check()) { + throw TenantMissing::make($tenancy->getName()); + } + + $tenant = $tenancy->tenant(); + + if (! isset($config['override'])) { + throw new RuntimeException('No cache store provided to override'); + } + + /** @var array $storeConfig */ + $storeConfig = config('caches.store.' . $config['override']); + $prefix = ( + isset($storeConfig['prefix']) + ? $storeConfig['prefix'] . '_' + : '' + ) + . $tenancy->getName() + . '_' + . $tenant->getTenantKey(); + + /** @var array{driver:string,serialize?:bool,path:string,permission?:int|null,lock_path?:string|null} $storeConfig */ + + /** @var string $storeName */ + $storeName = config('store'); + + if (! in_array($storeName, self::$purgableStores, true)) { + self::$purgableStores[] = $storeName; + } + + return $cacheManager->repository(match ($storeConfig['driver']) { + 'apc' => new ApcStore(new ApcWrapper(), $prefix), + 'array' => new ArrayStore($storeConfig['serialize'] ?? false), + 'file' => (new FileStore(app('files'), $storeConfig['path'], $storeConfig['permission'] ?? null)) + ->setLockDirectory($storeConfig['lock_path'] ?? null), + 'null' => new NullStore(), + 'memcached' => $this->createTenantedMemcachedStore($prefix, $storeConfig), + 'redis' => $this->createTenantedRedisStore($prefix, $storeConfig), + 'database' => $this->createTenantedDatabaseStore($prefix, $storeConfig), + default => throw new RuntimeException('Unsupported cache driver'), + }, array_merge($config, $storeConfig)); + + } + ); + } + + /** + * @param string $prefix + * @param array $config + * + * @return \Illuminate\Cache\MemcachedStore + */ + private function createTenantedMemcachedStore(string $prefix, array $config): MemcachedStore + { + /** @var array{servers:array,persistent_id?:string|null, options?:array|null,sasl?:array|null} $config */ + + $memcached = app('memcached.connector')->connect( + $config['servers'], + $config['persistent_id'] ?? null, + $config['options'] ?? [], + array_filter($config['sasl'] ?? []) + ); + + return new MemcachedStore($memcached, $prefix); + } + + /** + * @param string $prefix + * @param array $config + * + * @return \Illuminate\Cache\RedisStore + */ + private function createTenantedRedisStore(string $prefix, array $config): RedisStore + { + /** @var array{connection?:string|null, lock_connection?:string|null} $config */ + $redis = app('redis'); + + $connection = $config['connection'] ?? 'default'; + + return (new RedisStore($redis, $prefix, $connection))->setLockConnection($config['lock_connection'] ?? $connection); + } + + /** + * @param string $prefix + * @param array $config + * + * @return \Illuminate\Cache\DatabaseStore + */ + private function createTenantedDatabaseStore(string $prefix, array $config): DatabaseStore + { + /** @var array{table:string,lock_table?:string|null,lock_lottery?:array|null,lock_timeout?:int|null,connection?:string|null, lock_connection?:string|null} $config */ + $connection = app('db')->connection($config['connection'] ?? null); + + $store = new DatabaseStore( + $connection, + $config['table'], + $prefix, + $config['lock_table'] ?? 'cache_locks', + $config['lock_lottery'] ?? [2, 100], + $config['lock_timeout'] ?? 86400, + ); + + if (isset($config['lock_connection'])) { + $store->setLockConnection(app('db')->connection($config['lock_connection'])); + } else { + $store->setLockConnection($connection); + } + + return $store; + } + + /** + * Set up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when a new tenant is marked as the current tenant. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function setup(Tenancy $tenancy, Tenant $tenant): void + { + // This is intentionally empty, nothing to do here + } + + /** + * Clean up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when the current tenant is unset, either to be replaced + * by another tenant, or none. + * + * It will be called before {@see self::setup()}, but only if the previous + * tenant was not null. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function cleanup(Tenancy $tenancy, Tenant $tenant): void + { + app(CacheManager::class)->forgetDriver(self::$purgableStores); + } +} diff --git a/src/Overrides/CookieOverride.php b/src/Overrides/CookieOverride.php new file mode 100644 index 0000000..4971ac4 --- /dev/null +++ b/src/Overrides/CookieOverride.php @@ -0,0 +1,96 @@ + $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function setup(Tenancy $tenancy, Tenant $tenant): void + { + // Collect the values + $path = self::$path ?? config('session.path') ?? '/'; + $domain = self::$domain ?? config('session.domain'); + $secure = self::$secure ?? config('session.secure', false); + $sameSite = self::$sameSite ?? config('session.same_site'); + + /** + * This is here to make PHPStan quiet down + * + * @var string $path + * @var string|null $domain + * @var bool|null $secure + * @var string|null $sameSite + */ + + // Set the default values on the cookiejar + app(CookieJar::class)->setDefaultPathAndDomain($path, $domain, $secure, $sameSite); + } + + /** + * Clean up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when the current tenant is unset, either to be replaced + * by another tenant, or none. + * + * It will be called before {@see self::setup()}, but only if the previous + * tenant was not null. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function cleanup(Tenancy $tenancy, Tenant $tenant): void + { + // This is intentionally empty + } +} diff --git a/src/Overrides/Session/DatabaseSessionHandler.php b/src/Overrides/Session/DatabaseSessionHandler.php new file mode 100644 index 0000000..9da1c90 --- /dev/null +++ b/src/Overrides/Session/DatabaseSessionHandler.php @@ -0,0 +1,30 @@ +getCurrentTenancy(); + + if ($tenancy === null) { + throw new RuntimeException('No current tenancy'); + } + + if ($tenancy->check() === false) { + throw TenantMissing::make($tenancy->getName()); + } + + return parent::getQuery() + ->where('tenancy', '=', $tenancy->getName()) + ->where('tenant_id', '=', $tenancy->key()); + } +} diff --git a/src/Overrides/SessionOverride.php b/src/Overrides/SessionOverride.php new file mode 100644 index 0000000..5024e57 --- /dev/null +++ b/src/Overrides/SessionOverride.php @@ -0,0 +1,227 @@ +extend('file', $fileCreator); + $sessionManager->extend('native', $fileCreator); + + if (self::$overrideDatabase) { + $sessionManager->extend('database', self::createDatabaseDriver()); + } + } + + /** + * Set up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when a new tenant is marked as the current tenant. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function setup(Tenancy $tenancy, Tenant $tenant): void + { + // Collect the values + $path = self::$path ?? config('session.path') ?? '/'; + $domain = self::$domain ?? config('session.domain'); + $secure = self::$secure ?? config('session.secure', false); + $sameSite = self::$sameSite ?? config('session.same_site'); + + /** + * This is here to make PHPStan quiet down + * + * @var string $path + * @var string|null $domain + * @var bool|null $secure + * @var string|null $sameSite + */ + + /** @var \Illuminate\Contracts\Config\Repository $config */ + $config = config(); + + // Set the config values + $config->set('session.path', $path); + $config->set('session.domain', $domain); + $config->set('session.secure', $secure); + $config->set('session.same_site', $sameSite); + $config->set('session.cookie', $this->getCookieName($tenancy, $tenant)); + + // Reset all the drivers + app(SessionManager::class)->forgetDrivers(); + } + + /** + * Clean up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when the current tenant is unset, either to be replaced + * by another tenant, or none. + * + * It will be called before {@see self::setup()}, but only if the previous + * tenant was not null. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function cleanup(Tenancy $tenancy, Tenant $tenant): void + { + // Reset all the drivers + app(SessionManager::class)->forgetDrivers(); + } + + /** + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return string + */ + private function getCookieName(Tenancy $tenancy, Tenant $tenant): string + { + return $tenancy->getName() . '_' . $tenant->getTenantIdentifier() . '_session'; + } + + /** + * Get a creator for a tenant scoped file session handler + * + * @return \Closure + */ + private static function createFilesDriver(): Closure + { + return static function (): FileSessionHandler { + /** @var string $originalPath */ + $originalPath = config('session.files'); + $path = rtrim($originalPath, '/') . DIRECTORY_SEPARATOR; + $tenancy = sprout()->getCurrentTenancy(); + + if ($tenancy === null) { + throw new RuntimeException('No current tenancy'); + } + + // If there's no tenant, error out + if (! $tenancy->check()) { + throw TenantMissing::make($tenancy->getName()); + } + + $tenant = $tenancy->tenant(); + + // If the tenant isn't configured for resources, also error out + if (! ($tenant instanceof TenantHasResources)) { + // TODO: Better exception + throw new RuntimeException('Current tenant isn\t configured for resources'); + } + + $path .= $tenant->getTenantResourceKey(); + + /** @var int $lifetime */ + $lifetime = config('session.lifetime'); + + return new FileSessionHandler( + app()->make('files'), + $path, + $lifetime, + ); + }; + } + + private static function createDatabaseDriver(): Closure + { + return static function (): DatabaseSessionHandler { + $table = config('session.table'); + $lifetime = config('session.lifetime'); + $connection = config('session.connection'); + + /** + * @var string|null $connection + * @var string $table + * @var int $lifetime + */ + + return new DatabaseSessionHandler( + app()->make('db')->connection($connection), + $table, + $lifetime, + app() + ); + }; + } +} diff --git a/src/Support/StorageHelper.php b/src/Overrides/StorageOverride.php similarity index 52% rename from src/Support/StorageHelper.php rename to src/Overrides/StorageOverride.php index d83ada7..725a52b 100644 --- a/src/Support/StorageHelper.php +++ b/src/Overrides/StorageOverride.php @@ -1,26 +1,94 @@ make(FilesystemManager::class); + $filesystemManager->extend('sprout', self::creator($sprout, $filesystemManager)); + } + + /** + * Set up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when a new tenant is marked as the current tenant. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function setup(Tenancy $tenancy, Tenant $tenant): void { - return static function (Application $app, array $config) use ($sprout, $manager): ?Filesystem { - if ($sprout->config('services.storage', false) === false) { - return null; + // This is intentionally empty, nothing to do here + } + + /** + * Clean up the service override + * + * This method should perform any necessary setup actions for the service + * override. + * It is called when the current tenant is unset, either to be replaced + * by another tenant, or none. + * + * It will be called before {@see self::setup()}, but only if the previous + * tenant was not null. + * + * @param \Sprout\Contracts\Tenancy<*> $tenancy + * @param \Sprout\Contracts\Tenant $tenant + * + * @return void + */ + public function cleanup(Tenancy $tenancy, Tenant $tenant): void + { + /** @var array> $diskConfig */ + $diskConfig = config('filesystems.disks', []); + + /** @var \Illuminate\Filesystem\FilesystemManager $filesystemManager */ + $filesystemManager = app(FilesystemManager::class); + + // If any of the disks have the 'sprout' driver, we need to purge them + // if they exist, so we don't end up leaking tenant information + foreach ($diskConfig as $disk => $config) { + if (($config['driver'] ?? null) === 'sprout') { + $filesystemManager->forgetDisk($disk); } + } + } + private static function creator(Sprout $sprout, FilesystemManager $manager): Closure + { + return static function (Application $app, array $config) use ($sprout, $manager): Filesystem { $tenancy = $sprout->tenancies()->get($config['tenancy'] ?? null); // If there's no tenant, error out diff --git a/src/Sprout.php b/src/Sprout.php index 6508772..71ef740 100644 --- a/src/Sprout.php +++ b/src/Sprout.php @@ -4,8 +4,8 @@ namespace Sprout; use Illuminate\Contracts\Foundation\Application; +use Sprout\Contracts\ServiceOverride; use Sprout\Contracts\Tenancy; -use Sprout\Contracts\Tenant; use Sprout\Managers\IdentityResolverManager; use Sprout\Managers\ProviderManager; use Sprout\Managers\TenancyManager; @@ -22,6 +22,11 @@ final class Sprout */ private array $tenancies = []; + /** + * @var array, \Sprout\Contracts\ServiceOverride> + */ + private array $overrides = []; + public function __construct(Application $app) { $this->app = $app; @@ -73,7 +78,7 @@ public function getAllCurrentTenancies(): array public function shouldListenForRouting(): bool { - return (bool) $this->config('listen_for_routing', true); + return (bool)$this->config('listen_for_routing', true); } public function resolvers(): IdentityResolverManager @@ -90,4 +95,24 @@ public function tenancies(): TenancyManager { return $this->app->make(TenancyManager::class); } + + public function hasOverride(string $class): bool + { + return isset($this->overrides[$class]); + } + + public function addOverride(ServiceOverride $override): self + { + $this->overrides[$override::class] = $override; + + return $this; + } + + /** + * @return array, \Sprout\Contracts\ServiceOverride> + */ + public function getOverrides(): array + { + return $this->overrides; + } } diff --git a/src/SproutServiceProvider.php b/src/SproutServiceProvider.php index 4f8fdfa..be7c73a 100644 --- a/src/SproutServiceProvider.php +++ b/src/SproutServiceProvider.php @@ -4,26 +4,21 @@ namespace Sprout; use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Foundation\Application; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; -use RuntimeException; -use Sprout\Contracts\TenantHasResources; +use InvalidArgumentException; +use Sprout\Contracts\BootableServiceOverride; +use Sprout\Contracts\ServiceOverride; use Sprout\Events\CurrentTenantChanged; use Sprout\Http\Middleware\TenantRoutes; use Sprout\Http\RouterMethods; -use Sprout\Listeners\CleanupLaravelServices; use Sprout\Listeners\IdentifyTenantOnRouting; -use Sprout\Listeners\PerformIdentityResolverSetup; -use Sprout\Listeners\SetCurrentTenantContext; use Sprout\Listeners\SetCurrentTenantForJob; use Sprout\Managers\IdentityResolverManager; use Sprout\Managers\ProviderManager; use Sprout\Managers\TenancyManager; -use Sprout\Support\StorageHelper; class SproutServiceProvider extends ServiceProvider { @@ -91,13 +86,32 @@ protected function registerRouteMixin(): void public function boot(): void { $this->publishConfig(); - $this->registerEventListeners(); $this->registerServiceOverrides(); + $this->registerEventListeners(); + $this->registerTenancyBootstrappers(); + $this->bootServiceOverrides(); } private function publishConfig(): void { - $this->publishes([__DIR__ . '/../resources/config/multitenancy.php' => config_path('multitenancy.php')], 'config'); + $this->publishes([__DIR__ . '/../resources/config/multitenancy.php' => config_path('multitenancy.php')], ['config', 'sprout-config']); + } + + private function registerServiceOverrides(): void + { + /** @var array> $overrides */ + $overrides = config('sprout.services', []); + + foreach ($overrides as $overrideClass) { + if (! is_subclass_of($overrideClass, ServiceOverride::class)) { + throw new InvalidArgumentException('Provided class [' . $overrideClass . '] does not implement ' . ServiceOverride::class); + } + + /** @var \Sprout\Contracts\ServiceOverride $override */ + $override = $this->app->make($overrideClass); + + $this->sprout->addOverride($override); + } } private function registerEventListeners(): void @@ -110,19 +124,28 @@ private function registerEventListeners(): void $events->listen(RouteMatched::class, IdentifyTenantOnRouting::class); } - $events->listen(CurrentTenantChanged::class, SetCurrentTenantContext::class); - $events->listen(CurrentTenantChanged::class, PerformIdentityResolverSetup::class); - $events->listen(CurrentTenantChanged::class, CleanupLaravelServices::class); $events->listen(JobProcessing::class, SetCurrentTenantForJob::class); } - private function registerServiceOverrides(): void + private function registerTenancyBootstrappers(): void + { + /** @var \Illuminate\Contracts\Events\Dispatcher $events */ + $events = $this->app->make(Dispatcher::class); + + /** @var array $bootstrappers */ + $bootstrappers = config('sprout.bootstrappers', []); + + foreach ($bootstrappers as $bootstrapper) { + $events->listen(CurrentTenantChanged::class, $bootstrapper); + } + } + + private function bootServiceOverrides(): void { - // If we're providing a tenanted override for Laravels filesystem/storage - // service, we'll do that here - if ($this->sprout->config('services.storage', false)) { - $filesystemManager = $this->app->make(FilesystemManager::class); - $filesystemManager->extend('sprout', StorageHelper::creator($this->sprout, $filesystemManager)); + foreach ($this->sprout->getOverrides() as $override) { + if ($override instanceof BootableServiceOverride) { + $override->boot($this->app, $this->sprout); + } } } } diff --git a/src/Support/CookieHelper.php b/src/Support/CookieHelper.php deleted file mode 100644 index 95aee86..0000000 --- a/src/Support/CookieHelper.php +++ /dev/null @@ -1,36 +0,0 @@ -setDefaultPathAndDomain($path, $domain, $secure, $sameSite); - } - - public static function collectDefaults(?string &$path = null, ?string &$domain = null, ?bool &$secure = null, ?string &$sameSite = null): void - { - // Collect the defaults for the values - $path ??= config('session.path'); - $domain ??= config('session.domain'); - $secure ??= config('session.secure', false); - $sameSite ??= config('session.same_site'); - } -} diff --git a/testbench.yaml b/testbench.yaml index 99946a1..f4710bd 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -18,8 +18,9 @@ workbench: components: false views: false build: - - vendor:publish --provider="Sprout\\SproutServiceProvider" - - crete-sqlite-db + - asset-publish + - create-sqlite-db - migrate:refresh - assets: [] + assets: + - sprout-config sync: [] diff --git a/tests/Services/CookieTest.php b/tests/Overrides/CookieOverrideTest.php similarity index 90% rename from tests/Services/CookieTest.php rename to tests/Overrides/CookieOverrideTest.php index b740d1e..42d5415 100644 --- a/tests/Services/CookieTest.php +++ b/tests/Overrides/CookieOverrideTest.php @@ -1,22 +1,26 @@ set('sprout.services', [ + CacheOverride::class, + StorageOverride::class, + SessionOverride::class, + ]); + }); + } + protected function defineRoutes($router): void { $router->get('/', function () { @@ -108,11 +123,9 @@ public function setsTheCookiePathWhenUsingThePathIdentityResolver(): void $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); } - #[Test] + #[Test, DefineEnvironment('noCookieOverride')] public function doesNotSetTheCookieDomainWhenUsingTheSubdomainIdentityResolverIfDisabled(): void { - config()->set('sprout.services.cookies', false); - $tenant = TenantModel::factory()->createOne(); $result = $this->get(route('subdomain.route', [$tenant->getTenantIdentifier()])); @@ -129,11 +142,9 @@ public function doesNotSetTheCookieDomainWhenUsingTheSubdomainIdentityResolverIf $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); } - #[Test] + #[Test, DefineEnvironment('noCookieOverride')] public function doesNotSetTheCookiePathWhenUsingThePathIdentityResolverIfDisabled(): void { - config()->set('sprout.services.cookies', false); - $tenant = TenantModel::factory()->createOne(); $result = $this->get(route('path.route', [$tenant->getTenantIdentifier()])); diff --git a/tests/Services/FilesystemTest.php b/tests/Overrides/StorageOverrideTest.php similarity index 73% rename from tests/Services/FilesystemTest.php rename to tests/Overrides/StorageOverrideTest.php index ed252ef..8a6a2ac 100644 --- a/tests/Services/FilesystemTest.php +++ b/tests/Overrides/StorageOverrideTest.php @@ -1,11 +1,13 @@ set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + protected function createTenantDisk($app): void + { + tap($app['config'], static function (Repository $config) { $config->set('filesystems.disks.tenant', [ 'driver' => 'sprout', 'disk' => 'local', @@ -35,7 +46,18 @@ protected function defineEnvironment($app): void }); } - #[Test] + protected function noStorageOverride($app): void + { + tap($app['config'], static function (Repository $config) { + $config->set('sprout.services', [ + CacheOverride::class, + CookieOverride::class, + SessionOverride::class, + ]); + }); + } + + #[Test, DefineEnvironment('createTenantDisk')] public function canCreateScopedTenantFilesystemDisk(): void { $tenant = TenantModel::factory()->createOne(); @@ -48,7 +70,7 @@ public function canCreateScopedTenantFilesystemDisk(): void $this->assertSame($tenant->getTenantResourceKey(), basename($disk->path(''))); } - #[Test] + #[Test, DefineEnvironment('createTenantDisk')] public function canCreateScopedTenantFilesystemDiskWithCustomConfig(): void { config()->set('filesystems.disks.tenant.disk', config('filesystems.disks.local')); @@ -63,7 +85,7 @@ public function canCreateScopedTenantFilesystemDiskWithCustomConfig(): void $this->assertSame($tenant->getTenantResourceKey(), basename($disk->path(''))); } - #[Test] + #[Test, DefineEnvironment('createTenantDisk')] public function throwsExceptionIfThereIsNoTenant(): void { $this->expectException(TenantMissing::class); @@ -72,7 +94,7 @@ public function throwsExceptionIfThereIsNoTenant(): void Storage::disk('tenant'); } - #[Test] + #[Test, DefineEnvironment('createTenantDisk')] public function throwsExceptionIfTheTenantDoesNotHaveResources(): void { $this->expectException(RuntimeException::class); @@ -85,7 +107,7 @@ public function throwsExceptionIfTheTenantDoesNotHaveResources(): void Storage::disk('tenant'); } - #[Test] + #[Test, DefineEnvironment('createTenantDisk')] public function cleansUpStorageDiskAfterTenantChange(): void { $tenant = TenantModel::factory()->createOne(); @@ -99,7 +121,7 @@ public function cleansUpStorageDiskAfterTenantChange(): void app(TenancyManager::class)->get()->setTenant(null); } - #[Test] + #[Test, DefineEnvironment('createTenantDisk')] public function recreatesStorageDiskPerTenant(): void { $tenant1 = TenantModel::factory()->createOne(); @@ -121,15 +143,14 @@ public function recreatesStorageDiskPerTenant(): void $this->assertSame($tenant2->getTenantResourceKey(), basename($disk->path(''))); } - #[Test] - public function doesNothingIfStorageServiceIsDisabled(): void + #[Test, DefineEnvironment('noStorageOverride')] + public function doesNotOverrideStorageIfDisabled(): void { - config()->set('sprout.services.storage', false); - app(TenancyManager::class)->get()->setTenant(TenantModel::factory()->createOne()); - $disk = Storage::disk('tenant'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Disk [tenant] does not have a configured driver.'); - $this->assertNull($disk); + Storage::disk('tenant'); } } diff --git a/tests/Resolvers/SessionResolverTest.php b/tests/Resolvers/SessionResolverTest.php index 562d454..612c5f1 100644 --- a/tests/Resolvers/SessionResolverTest.php +++ b/tests/Resolvers/SessionResolverTest.php @@ -13,6 +13,9 @@ use PHPUnit\Framework\Attributes\Test; use Sprout\Attributes\CurrentTenant; use Sprout\Contracts\Tenant; +use Sprout\Overrides\CacheOverride; +use Sprout\Overrides\CookieOverride; +use Sprout\Overrides\StorageOverride; use Workbench\App\Models\TenantModel; #[Group('resolvers'), Group('sessions')] @@ -31,10 +34,15 @@ protected function defineEnvironment($app): void 'driver' => 'session', 'session' => 'multitenancy.{tenancy}', ]); + $config->set('sprout.services', [ + StorageOverride::class, + CacheOverride::class, + CookieOverride::class, + ]); }); } - protected function defineRoutes($router) + protected function defineRoutes($router): void { $router->middleware(StartSession::class)->group(function (Router $router) { $router->get('/', function () {