1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-05-07 06:14:03 +00:00

Merge branch 'master' into add-log-bootstrapper

This commit is contained in:
Samuel Štancl 2026-04-12 14:02:39 +02:00 committed by GitHub
commit 39fc72bea5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1127 additions and 221 deletions

View file

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

View file

@ -15,7 +15,7 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
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:
* [

View file

@ -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_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
$names[] = $this->getSessionCacheStoreName();

View file

@ -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;

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Session\FileSessionHandler;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
@ -22,13 +21,6 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
) {
$this->originalAssetUrl = $this->app['config']['app.asset_url'];
$this->originalStoragePath = $app->storagePath();
$this->app['url']->macro('setAssetRoot', function ($root) {
/** @var UrlGenerator $this */
$this->assetRoot = $root;
return $this;
});
}
public function bootstrap(Tenant $tenant): void
@ -78,6 +70,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 {
@ -98,22 +99,33 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
if ($suffix === false) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl;
$this->app['url']->setAssetRoot($this->originalAssetUrl);
$this->app['url']->useAssetOrigin($this->originalAssetUrl);
return;
}
if ($this->originalAssetUrl) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix";
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
$this->app['url']->useAssetOrigin($this->app['config']['app.asset_url']);
} else {
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
$this->app['url']->useAssetOrigin($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
}
}
protected function forgetDisks(): void
{
Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']);
$tenantDisks = $this->app['config']['tenancy.filesystem.disks'];
$scopedDisks = [];
foreach ($this->app['config']['filesystems.disks'] as $name => $disk) {
if (isset($disk['driver'], $disk['disk'])
&& $disk['driver'] === 'scoped'
&& in_array($disk['disk'], $tenantDisks, true)) {
$scopedDisks[] = $name;
}
}
Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks));
}
protected function diskRoot(string $disk, Tenant|false $tenant): void
@ -211,7 +223,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;

View file

@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant;
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:
* [

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
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->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

@ -63,7 +63,7 @@ class TenantDump extends DumpCommand
protected function getOptions(): array
{
return array_merge([
['tenant', null, InputOption::VALUE_OPTIONAL, '', null],
new InputOption('tenant', null, InputOption::VALUE_OPTIONAL, '', null),
], parent::getOptions());
}
}

View file

@ -17,8 +17,8 @@ trait HasTenantOptions
protected function getOptions()
{
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],
['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'],
new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null),
new InputOption('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());
}

View file

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

View file

@ -12,6 +12,17 @@ trait BelongsToPrimaryModel
abstract public function getRelationshipToPrimaryModel(): string;
public static function bootBelongsToPrimaryModel(): void
{
if (method_exists(static::class, 'whenBooted')) {
// Laravel 13
// For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92
static::whenBooted(fn () => static::configureBelongsToPrimaryModelScope());
} else {
static::configureBelongsToPrimaryModelScope();
}
}
protected static function configureBelongsToPrimaryModelScope()
{
$implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;

View file

@ -17,12 +17,26 @@ trait BelongsToTenant
{
use FillsCurrentTenant;
/**
* @return BelongsTo<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
}
public static function bootBelongsToTenant(): void
{
if (method_exists(static::class, 'whenBooted')) {
// Laravel 13
// For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92
static::whenBooted(fn () => static::configureBelongsToTenantScope());
} else {
static::configureBelongsToTenantScope();
}
}
protected static function configureBelongsToTenantScope(): void
{
// If TraitRLSManager::$implicitRLS is true or this model implements RLSModel
// Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy.

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Tenancy;
@ -14,7 +15,10 @@ use Stancl\Tenancy\Tenancy;
*/
trait HasDomains
{
public function domains()
/**
* @return HasMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Domain, $this>
*/
public function domains(): HasMany
{
return $this->hasMany(config('tenancy.models.domain'), Tenancy::tenantKeyColumn());
}

View file

@ -49,13 +49,15 @@ trait HasPending
*/
public static function createPending(array $attributes = []): Model&Tenant
{
$tenant = null;
try {
$tenant = static::create($attributes);
$tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes));
event(new CreatingPendingTenant($tenant));
} finally {
// Update the pending_since value only after the tenant is created so it's
// not marked as pending until after migrations, seeders, etc are run.
$tenant->update([
$tenant?->update([
'pending_since' => now()->timestamp,
]);
}
@ -65,6 +67,17 @@ trait HasPending
return $tenant;
}
/**
* Attributes to be set when a pending tenant is initially created.
*
* @param array<string, mixed> $attributes The attributes passed to createPending() (will be merged with the returned array)
* @return array<string, mixed>
*/
public static function getPendingAttributes(array $attributes): array
{
return [];
}
/**
* Pull a pending tenant from the pool or create a new one if the pool is empty.
*

View file

@ -10,13 +10,6 @@ use Illuminate\Database\Eloquent\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.
*
@ -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
*
* @return void
*/
public function extend(Builder $builder)
public function extend(Builder $builder): void
{
foreach ($this->extensions as $extension) {
$this->{"add{$extension}"}($builder);
}
$this->addWithPending($builder);
$this->addWithoutPending($builder);
$this->addOnlyPending($builder);
}
/**
* Add the with-pending extension to the 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) {
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
*
* @return void
*/
protected function addWithoutPending(Builder $builder)
protected function addWithoutPending(Builder $builder): void
{
$builder->macro('withoutPending', function (Builder $builder) {
$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
*
* @return void
*/
protected function addOnlyPending(Builder $builder)
protected function addOnlyPending(Builder $builder): void
{
$builder->macro('onlyPending', function (Builder $builder) {
$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;
abort_if($tokenExpired, 403);
if ($tokenExpired) {
$token->delete();
abort(403);
}
$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
$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);

View file

@ -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);
}
}
}

View file

@ -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());

View file

@ -129,7 +129,15 @@ class TenancyUrlGenerator extends UrlGenerator
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
}
[$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type
$wrappedParameters = Arr::wrap($parameters);
[$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type
if (isset($wrappedParameters[static::$bypassParameter])) {
// If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it,
// so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well.
$parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter];
}
return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute);
}

View file

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

View file

@ -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\ResourceSyncing\Events\CentralResourceAttachedToTenant;
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
@ -19,37 +20,34 @@ trait ResourceSyncing
{
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()))) {
$model->triggerSyncEvent();
}
});
static::deleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
static::deleted(static function (Syncable&Model $model) {
if ($model->shouldSync()) {
$model->triggerDeleteEvent();
}
});
static::creating(function (Syncable&Model $model) {
if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) {
$model->setAttribute(
$model->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($model)
);
static::creating(static function (Syncable&Model $model) {
if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) {
$model->generateGlobalIdentifierKey();
}
});
if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) {
static::forceDeleting(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
static::forceDeleting(static function (Syncable&Model $model) {
if ($model->shouldSync()) {
$model->triggerDeleteEvent(true);
}
});
static::restoring(function (Syncable&Model $model) {
if ($model->shouldSync() && $model instanceof SyncMaster) {
$model->triggerRestoredEvent();
static::restoring(static function (Syncable&Model $model) {
if ($model instanceof SyncMaster && $model->shouldSync()) {
$model->triggerRestoreEvent();
}
});
}
@ -67,9 +65,11 @@ trait ResourceSyncing
/** @var SyncMaster&Model $this */
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)) {
/** @var SyncMaster&Model $this */
@ -105,6 +105,9 @@ trait ResourceSyncing
return true;
}
/**
* @return BelongsToMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Database\Contracts\TenantWithDatabase, $this>
*/
public function tenants(): BelongsToMany
{
return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', $this->getGlobalIdentifierKeyName())
@ -116,8 +119,18 @@ trait ResourceSyncing
return 'global_id';
}
public function getGlobalIdentifierKey(): string
public function getGlobalIdentifierKey(): string|int
{
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 triggerDeleteEvent(bool $forceDelete = false): void;
public function triggerRestoredEvent(): void;
public function triggerRestoreEvent(): void;
}

View file

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

View file

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

View file

@ -152,6 +152,26 @@ class Tenancy
$this->initialized = false;
}
/**
* End tenancy and initialize it again for the current tenant.
*
* This can be helpful when changing "dependencies" of bootstrappers such as
* attributes of the current tenant that are only read once, during bootstrap().
*
* If tenancy is not initialized, this method is a no-op.
*/
public function reinitialize(): void
{
if ($this->tenant === null) {
return;
}
$tenant = $this->tenant;
$this->end();
$this->initialize($tenant);
}
/** @return TenancyBootstrapper[] */
public function getBootstrappers(): array
{

View file

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

View file

@ -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
{

View file

@ -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
{

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();
}
}