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

Merge branch 'master' into database-cache-bootstrapper

This commit is contained in:
lukinovec 2025-07-22 19:32:57 +02:00
commit 2cfa8831a3
113 changed files with 3035 additions and 1373 deletions

View file

@ -7,40 +7,72 @@ namespace Stancl\Tenancy\Actions;
use Closure;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
/**
* The CloneRoutesAsTenant action clones
* routes flagged with the 'universal' middleware,
* all routes without a flag if the default route mode is universal,
* and routes that directly use the InitializeTenancyByPath middleware.
* Clones either all existing routes for which shouldBeCloned() returns true
* (by default, all routes with any middleware present in $cloneRoutesWithMiddleware),
* or if any routes were manually added to $routesToClone using $action->cloneRoute($route),
* clone just the routes in $routesToClone. This means that only the routes specified
* by cloneRoute() (which can be chained infinitely -- you can specify as many routes as you want)
* will be cloned.
*
* The main purpose of this action is to make the integration
* of packages (e.g., Jetstream or Livewire) easier with path-based tenant identification.
* The main purpose of this action is to make the integration of packages
* (e.g., Jetstream or Livewire) easier with path-based tenant identification.
*
* By default, universal routes are cloned as tenant routes (= they get flagged with the 'tenant' middleware)
* and prefixed with the '/{tenant}' path prefix. Their name also gets prefixed with the tenant name prefix.
* The default for $cloneRoutesWithMiddleware is ['clone'].
* If $routesToClone is empty, all routes with any middleware specified in $cloneRoutesWithMiddleware will be cloned.
* The middleware can be in a group, nested as deep as you want
* (e.g. if a route has a 'foo' middleware which is a group containing the 'clone' middleware, the route will be cloned).
*
* Routes with the path identification middleware get cloned similarly, but only if they're not universal at the same time.
* Unlike universal routes, these routes don't get the tenant flag,
* because they don't need it (they're not universal, and they have the identification MW, so they're already considered tenant).
* You may customize $cloneRoutesWithMiddleware using cloneRoutesWithMiddleware() to make any middleware of your choice trigger cloning.
* 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.
*
* You can use the `cloneUsing()` hook to customize the route definitions,
* and the `skipRoute()` method to skip cloning of specific routes.
* You can also use the $tenantParameterName and $tenantRouteNamePrefix
* static properties to customize the tenant parameter name or the route name prefix.
* Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'.
* The parameter name and prefix can be changed e.g. to `/{team}` and `team.` by configuring the path resolver (tenantParameterName and tenantRouteNamePrefix).
* 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.
*
* Note that routes already containing the tenant parameter or prefix won't be cloned.
* After cloning, only top-level middleware in $cloneRoutesWithMiddleware 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.
*
* Routes that already contain the tenant parameter or have names with the tenant prefix
* will not be cloned.
*
* Example usage:
* ```
* Route::get('/foo', fn () => true)->name('foo')->middleware('clone');
* Route::get('/bar', fn () => true)->name('bar')->middleware('universal');
*
* $cloneAction = app(CloneRoutesAsTenant::class);
*
* // Clone foo route as /{tenant}/foo/ and name it tenant.foo ('clone' middleware won't be present in the cloned route)
* $cloneAction->handle();
*
* // Clone bar route as /{tenant}/bar and name it tenant.bar ('universal' middleware won't be present in the cloned route)
* $cloneAction->cloneRoutesWithMiddleware(['universal'])->handle();
*
* Route::get('/baz', fn () => true)->name('baz');
*
* // Clone baz route as /{tenant}/bar and name it tenant.baz ('universal' middleware won't be present in the cloned route)
* $cloneAction->cloneRoute('baz')->handle();
* ```
*
* Calling handle() will also clear the $routesToClone array.
* This means that $action->cloneRoute('foo')->handle() will clone the 'foo' route, but subsequent calls to handle() will behave
* as if cloneRoute() wasn't called at all ($routesToClone will be empty).
* Note that calling handle() does not reset the other properties.
*
* @see Stancl\Tenancy\Resolvers\PathTenantResolver
*/
class CloneRoutesAsTenant
{
protected array $cloneRouteUsing = [];
protected array $skippedRoutes = [
'stancl.tenancy.asset',
];
protected array $routesToClone = [];
protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string)
protected Closure|null $shouldClone = null;
protected array $cloneRoutesWithMiddleware = ['clone'];
public function __construct(
protected Router $router,
@ -48,100 +80,77 @@ class CloneRoutesAsTenant
public function handle(): void
{
$this->getRoutesToClone()->each(fn (Route $route) => $this->cloneRoute($route));
// 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())
->filter(fn (Route $route) => $this->shouldBeCloned($route))
->all();
}
foreach ($this->routesToClone as $route) {
// If the cloneUsing callback is set,
// use the callback to clone the route instead of the default
if ($this->cloneUsing) {
($this->cloneUsing)($route);
continue;
}
if (is_string($route)) {
$this->router->getRoutes()->refreshNameLookups();
$route = $this->router->getRoutes()->getByName($route);
}
$this->copyMiscRouteProperties($route, $this->createNewRoute($route));
}
// Clean up the routesToClone array after cloning so that subsequent calls aren't affected
$this->routesToClone = [];
$this->router->getRoutes()->refreshNameLookups();
}
/**
* Make the action clone a specific route using the provided callback instead of the default one.
*/
public function cloneUsing(string $routeName, Closure $callback): static
public function cloneUsing(Closure|null $cloneUsing): static
{
$this->cloneRouteUsing[$routeName] = $callback;
$this->cloneUsing = $cloneUsing;
return $this;
}
/**
* Skip a route's cloning.
*/
public function skipRoute(string $routeName): static
public function cloneRoutesWithMiddleware(array $middleware): static
{
$this->skippedRoutes[] = $routeName;
$this->cloneRoutesWithMiddleware = $middleware;
return $this;
}
/**
* @return Collection<int, Route>
*/
protected function getRoutesToClone(): Collection
public function shouldClone(Closure|null $shouldClone): static
{
$tenantParameterName = PathTenantResolver::tenantParameterName();
$this->shouldClone = $shouldClone;
/**
* Clone all routes that:
* - don't have the tenant parameter
* - aren't in the $skippedRoutes array
* - are using path identification (kernel or route-level).
*
* Non-universal cloned routes will only be available in the tenant context,
* universal routes will be available in both contexts.
*/
return collect($this->router->getRoutes()->get())->filter(function (Route $route) use ($tenantParameterName) {
if (
tenancy()->routeHasMiddleware($route, 'tenant') ||
in_array($route->getName(), $this->skippedRoutes, true) ||
in_array($tenantParameterName, $route->parameterNames(), true)
) {
return false;
}
$pathIdentificationMiddleware = config('tenancy.identification.path_identification_middleware');
$routeHasPathIdentificationMiddleware = tenancy()->routeHasMiddleware($route, $pathIdentificationMiddleware);
$routeHasNonPathIdentificationMiddleware = tenancy()->routeHasIdentificationMiddleware($route) && ! $routeHasPathIdentificationMiddleware;
$pathIdentificationMiddlewareInGlobalStack = tenancy()->globalStackHasMiddleware($pathIdentificationMiddleware);
/**
* The route should get cloned if:
* - it has route-level path identification middleware, OR
* - it uses kernel path identification (it doesn't have any route-level identification middleware) and the route is tenant or universal.
*
* The route is considered tenant if:
* - it's flagged as tenant, OR
* - it's not flagged as tenant or universal, but it has the identification middleware
*
* The route is considered universal if it's flagged as universal, and it doesn't have the tenant flag
* (it's still considered universal if it has route-level path identification middleware + the universal flag).
*
* If the route isn't flagged, the context is determined using the default route mode.
*/
$pathIdentificationUsed = (! $routeHasNonPathIdentificationMiddleware) &&
($routeHasPathIdentificationMiddleware || $pathIdentificationMiddlewareInGlobalStack);
return $pathIdentificationUsed &&
(tenancy()->getRouteMode($route) === RouteMode::UNIVERSAL || tenancy()->routeHasMiddleware($route, 'clone'));
});
return $this;
}
/**
* Clone a route using a callback specified in the $cloneRouteUsing property (using the cloneUsing method).
* If there's no callback specified for the route, use the default way of cloning routes.
*/
protected function cloneRoute(Route $route): void
public function cloneRoute(Route|string $route): static
{
$routeName = $route->getName();
$this->routesToClone[] = $route;
// If the route's cloning callback exists
// Use the callback to clone the route instead of the default way of cloning routes
if ($routeName && $customRouteCallback = data_get($this->cloneRouteUsing, $routeName)) {
$customRouteCallback($route);
return $this;
}
return;
protected function shouldBeCloned(Route $route): bool
{
// Don't clone routes that already have tenant parameter or prefix
if ($this->routeIsTenant($route)) {
return false;
}
$this->copyMiscRouteProperties($route, $this->createNewRoute($route));
if ($this->shouldClone) {
return ($this->shouldClone)($route);
}
return tenancy()->routeHasMiddleware($route, $this->cloneRoutesWithMiddleware);
}
protected function createNewRoute(Route $route): Route
@ -150,33 +159,24 @@ class CloneRoutesAsTenant
$prefix = trim($route->getPrefix() ?? '', '/');
$uri = $route->getPrefix() ? Str::after($route->uri(), $prefix) : $route->uri();
$newRouteAction = collect($route->action)->tap(function (Collection $action) use ($route, $prefix) {
/** @var array $routeMiddleware */
$routeMiddleware = $action->get('middleware') ?? [];
$action = collect($route->action);
// Make the new route have the same middleware as the original route
// Add the 'tenant' middleware to the new route
// Exclude `universal` and `clone` middleware from the new route (it should only be flagged as tenant)
$newRouteMiddleware = collect($routeMiddleware)
->merge(['tenant']) // Add 'tenant' flag
->filter(fn (string $middleware) => ! in_array($middleware, ['universal', 'clone']))
->toArray();
// Make the new route have the same middleware as the original route
// Add the 'tenant' middleware to the new route
// Exclude $this->cloneRoutesWithMiddleware MW from the new route (it should only be flagged as tenant)
$tenantRouteNamePrefix = PathTenantResolver::tenantRouteNamePrefix();
$middleware = $this->processMiddlewareForCloning($action->get('middleware') ?? []);
// Make sure the route name has the tenant route name prefix
$newRouteNamePrefix = $route->getName()
? $tenantRouteNamePrefix . Str::after($route->getName(), $tenantRouteNamePrefix)
: null;
if ($name = $route->getName()) {
$action->put('as', PathTenantResolver::tenantRouteNamePrefix() . $name);
}
return $action
->put('as', $newRouteNamePrefix)
->put('middleware', $newRouteMiddleware)
->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}');
})->toArray();
$action
->put('middleware', $middleware)
->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}');
/** @var Route $newRoute */
$newRoute = $this->router->$method($uri, $newRouteAction);
$newRoute = $this->router->$method($uri, $action->toArray());
return $newRoute;
}
@ -194,4 +194,26 @@ class CloneRoutesAsTenant
->withTrashed($originalRoute->allowsTrashedBindings())
->setDefaults($originalRoute->defaults);
}
/** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */
protected function processMiddlewareForCloning(array $middleware): array
{
$processedMiddleware = array_filter(
$middleware,
fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware)
);
$processedMiddleware[] = 'tenant';
return array_unique($processedMiddleware);
}
/** Check if route already has tenant parameter or name prefix. */
protected function routeIsTenant(Route $route): bool
{
$routeHasTenantParameter = in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames());
$routeHasTenantPrefix = $route->getName() && str_starts_with($route->getName(), PathTenantResolver::tenantRouteNamePrefix());
return $routeHasTenantParameter || $routeHasTenantPrefix;
}
}

View file

@ -92,7 +92,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
protected function assetHelper(string|false $suffix): void
{
if (! $this->app['config']['tenancy.filesystem.asset_helper_tenancy']) {
if (! $this->app['config']['tenancy.filesystem.asset_helper_override']) {
return;
}

View file

@ -7,54 +7,63 @@ namespace Stancl\Tenancy\Bootstrappers\Integrations;
use Illuminate\Config\Repository;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Enums\Context;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
/**
* Allows customizing Fortify action redirects
* so that they can also redirect to tenant routes instead of just the central routes.
* Allows customizing Fortify action redirects so that they can also redirect
* to tenant routes instead of just the central routes.
*
* Works with path and query string identification.
* This should be used with path/query string identification OR when using Fortify
* universally, including with domains.
*
* When using domain identification, there's no need to pass the tenant parameter,
* you only want to customize the routes being used, so you can set $passTenantParameter
* to false.
*/
class FortifyRouteBootstrapper implements TenancyBootstrapper
{
/**
* Make Fortify actions redirect to custom routes.
* Fortify redirects that should be used in tenant context.
*
* For each route redirect, specify the intended route context (central or tenant).
* Based on the provided context, we pass the tenant parameter to the route (or not).
* The tenant parameter is only passed to the route when you specify its context as tenant.
*
* The route redirects should be in the following format:
*
* 'fortify_action' => [
* 'route_name' => 'tenant.route',
* 'context' => Context::TENANT,
* ]
*
* For example:
*
* FortifyRouteBootstrapper::$fortifyRedirectMap = [
* // On logout, redirect the user to the "bye" route in the central app
* 'logout' => [
* 'route_name' => 'bye',
* 'context' => Context::CENTRAL,
* ],
*
* // On login, redirect the user to the "welcome" route in the tenant app
* 'login' => [
* 'route_name' => 'welcome',
* 'context' => Context::TENANT,
* ],
* ];
* Syntax: ['redirect_name' => 'tenant_route_name']
*/
public static array $fortifyRedirectMap = [];
/**
* Should the tenant parameter be passed to fortify routes in the tenant context.
*
* This should be enabled with path/query string identification and disabled with domain identification.
*
* You may also disable this when using path/query string identification if passing the tenant parameter
* is handled in another way (TenancyUrlGenerator::$passTenantParameter for both,
* UrlGeneratorBootstrapper:$addTenantParameterToDefaults for path identification).
*/
public static bool $passTenantParameter = false;
/**
* Tenant route that serves as Fortify's home (e.g. a tenant dashboard route).
* This route will always receive the tenant parameter.
*/
public static string $fortifyHome = 'tenant.dashboard';
public static string|null $fortifyHome = 'tenant.dashboard';
/**
* Follow the query_parameter config instead of the tenant_parameter_name (path identification) config.
*
* This only has an effect when:
* - $passTenantParameter is enabled, and
* - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver.
*
* In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'),
* the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'.
*
* This is enabled by default because typically you will not need $passTenantParameter with path identification.
* UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification.
*
* On the other hand, when using request data identification (specifically query string) you WILL need to
* pass the parameter therefore you would use $passTenantParameter.
*/
public static bool $passQueryParameter = true;
protected array $originalFortifyConfig = [];
@ -76,27 +85,28 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper
protected function useTenantRoutesInFortify(Tenant $tenant): void
{
$tenantKey = $tenant->getTenantKey();
$tenantParameterName = PathTenantResolver::tenantParameterName();
if (static::$passQueryParameter) {
// todo@tests
$tenantParameterName = RequestDataTenantResolver::queryParameterName();
$tenantParameterValue = RequestDataTenantResolver::payloadValue($tenant);
} else {
$tenantParameterName = PathTenantResolver::tenantParameterName();
$tenantParameterValue = PathTenantResolver::tenantParameterValue($tenant);
}
$generateLink = function (array $redirect) use ($tenantKey, $tenantParameterName) {
// Specifying the context is only required with query string identification
// because with path identification, the tenant parameter should always present
$passTenantParameter = $redirect['context'] === Context::TENANT;
// Only pass the tenant parameter when the user should be redirected to a tenant route
return route($redirect['route_name'], $passTenantParameter ? [$tenantParameterName => $tenantKey] : []);
$generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) {
return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []);
};
// Get redirect URLs for the configured redirect routes
$redirects = array_merge(
$this->originalFortifyConfig['redirects'] ?? [], // Fortify config redirects
array_map(fn (array $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects
array_map(fn (string $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects
);
if (static::$fortifyHome) {
// Generate the home route URL with the tenant parameter and make it the Fortify home route
$this->config->set('fortify.home', route(static::$fortifyHome, [$tenantParameterName => $tenantKey]));
$this->config->set('fortify.home', $generateLink(static::$fortifyHome));
}
$this->config->set('fortify.redirects', $redirects);

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* Adds support for running queued tenant jobs in batches.
*
* @deprecated Doesn't seem to 1. be necessary, 2. work correctly in Laravel 11. Please don't use this bootstrapper, the class will be removed before release.
*/
class JobBatchBootstrapper implements TenancyBootstrapper
{
public function __construct(
protected Application $app,
) {}
public function bootstrap(Tenant $tenant): void
{
$this->deprecatedNotice();
}
protected function deprecatedNotice(): void
{
if ($this->app->environment() == 'local' && $this->app->hasDebugModeEnabled()) {
throw new Exception("JobBatchBootstrapper is not supported anymore, please remove it from your tenancy config. Job batches should work out of the box in Laravel 11. If they don't, please open a bug report.");
}
}
public function revert(): void
{
$this->deprecatedNotice();
}
}

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Events\JobRetryRequested;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Testing\Fakes\QueueFake;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class PersistentQueueTenancyBootstrapper implements TenancyBootstrapper
{
/** @var Repository */
protected $config;
/** @var QueueManager */
protected $queue;
/**
* The normal constructor is only executed after tenancy is bootstrapped.
* However, we're registering a hook to initialize tenancy. Therefore,
* we need to register the hook at service provider execution time.
*/
public static function __constructStatic(Application $app): void
{
static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
}
public function __construct(Repository $config, QueueManager $queue)
{
$this->config = $config;
$this->queue = $queue;
$this->setUpPayloadGenerator();
}
protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void
{
$previousTenant = null;
$dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) {
$previousTenant = tenant();
static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
});
$dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
$previousTenant = tenant();
static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
});
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls
$revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
if ($runningTests) {
static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant);
// We don't need to reset $previousTenant since the value will be set again when a job is processed.
}
// If we're not running tests, we remain in the tenant's context. This makes other JobProcessed
// listeners able to deserialize the job, including with SerializesModels, since the tenant connection
// remains open.
};
$dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
$dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
}
protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{
if (! $tenantId) {
// The job is not tenant-aware
if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context
tenancy()->end();
}
return;
}
// Re-initialize tenancy between all jobs even if the tenant is the same
// so that we don't work with an outdated tenant() instance in case it
// was updated outside the queue worker.
tenancy()->end();
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
}
protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void
{
// The job was not tenant-aware
if (! $tenantId) {
return;
}
// Revert back to the previous tenant
if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) {
tenancy()->initialize($previousTenant);
}
// End tenancy
if (tenant() && (! $previousTenant)) {
tenancy()->end();
}
}
protected function setUpPayloadGenerator(): void
{
$bootstrapper = &$this;
if (! $this->queue instanceof QueueFake) {
$this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
return $bootstrapper->getPayload($connection);
});
}
}
public function getPayload(string $connection): array
{
if (! tenancy()->initialized) {
return [];
}
if ($this->config["queue.connections.$connection.central"]) {
return [];
}
return [
'tenant_id' => tenant()->getTenantKey(),
];
}
public function bootstrap(Tenant $tenant): void {}
public function revert(): void {}
}

View file

@ -24,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
/** @var QueueManager */
protected $queue;
/**
* Don't persist the same tenant across multiple jobs even if they have the same tenant ID.
*
* This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again
* with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases.
*
* @var bool
*/
public static $forceRefresh = false;
/**
* The normal constructor is only executed after tenancy is bootstrapped.
* However, we're registering a hook to initialize tenancy. Therefore,
@ -68,9 +58,12 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
});
// If we're running tests, we make sure to clean up after any artisan('queue:work') calls
$revertToPreviousState = function ($event) use (&$previousTenant) {
static::revertToPreviousState($event, $previousTenant);
// In queue worker context, this reverts to the central context.
// In dispatchSync context, this reverts to the previous tenant's context.
// There's no need to reset $previousTenant here since it's always first
// set in the above listeners and the app is reverted back to that context.
static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant);
};
$dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
@ -79,61 +72,25 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{
if ($tenantId === null) {
// The job is not tenant-aware
if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context
tenancy()->end();
}
if (! $tenantId) {
return;
}
if (static::$forceRefresh) {
// Re-initialize tenancy between all jobs
if (tenancy()->initialized) {
tenancy()->end();
}
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
return;
}
if (tenancy()->initialized) {
// Tenancy is already initialized
if (tenant()->getTenantKey() === $tenantId) {
// It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant)
return;
}
}
// Tenancy was either not initialized, or initialized for a different tenant.
// Therefore, we initialize it for the correct tenant.
/** @var Tenant $tenant */
$tenant = tenancy()->find($tenantId);
tenancy()->initialize($tenant);
}
protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void
protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void
{
$tenantId = $event->job->payload()['tenant_id'] ?? null;
// The job was not tenant-aware
// The job was not tenant-aware so no context switch was done
if (! $tenantId) {
return;
}
// Revert back to the previous tenant
if (tenant() && $previousTenant?->isNot(tenant())) {
tenancy()->initialize($previousTenant);
}
// End tenancy
if (tenant() && (! $previousTenant)) {
// End tenancy when there's no previous tenant
// (= when running in a queue worker, not dispatchSync)
if (tenant() && ! $previousTenant) {
tenancy()->end();
}
}
@ -149,16 +106,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
}
}
public function bootstrap(Tenant $tenant): void
{
//
}
public function revert(): void
{
//
}
public function getPayload(string $connection): array
{
if (! tenancy()->initialized) {
@ -169,10 +116,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
return [];
}
$id = tenant()->getTenantKey();
return [
'tenant_id' => $id,
'tenant_id' => tenant()->getTenantKey(),
];
}
public function bootstrap(Tenant $tenant): void {}
public function revert(): void {}
}

View file

@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Bootstrappers;
use Closure;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
@ -36,28 +35,43 @@ class RootUrlBootstrapper implements TenancyBootstrapper
protected string|null $originalRootUrl = null;
/**
* Overriding the root url may cause issues in *some* tests, so you can disable
* the behavior by setting this property to false.
*/
public static bool $rootUrlOverrideInTests = true;
public function __construct(
protected UrlGenerator $urlGenerator,
protected Repository $config,
protected Application $app,
) {}
public function bootstrap(Tenant $tenant): void
{
if ($this->app->runningInConsole() && static::$rootUrlOverride) {
$this->originalRootUrl = $this->urlGenerator->to('/');
$newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl);
$this->urlGenerator->forceRootUrl($newRootUrl);
$this->config->set('app.url', $newRootUrl);
if (static::$rootUrlOverride === null) {
return;
}
if (! $this->app->runningInConsole()) {
return;
}
if ($this->app->runningUnitTests() && ! static::$rootUrlOverrideInTests) {
return;
}
$this->originalRootUrl = $this->app['url']->to('/');
$newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl);
$this->app['url']->forceRootUrl($newRootUrl);
$this->config->set('app.url', $newRootUrl);
}
public function revert(): void
{
if ($this->originalRootUrl) {
$this->urlGenerator->forceRootUrl($this->originalRootUrl);
$this->app['url']->forceRootUrl($this->originalRootUrl);
$this->config->set('app.url', $this->originalRootUrl);
}
}

View file

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
/**
* Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which:
@ -19,10 +20,20 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
* Used with path and query string identification.
*
* @see TenancyUrlGenerator
* @see \Stancl\Tenancy\Resolvers\PathTenantResolver
* @see PathTenantResolver
*/
class UrlGeneratorBootstrapper implements TenancyBootstrapper
{
/**
* Should the tenant route parameter get added to TenancyUrlGenerator::defaults().
*
* This is recommended when using path identification since defaults() generally has better support in integrations,
* namely Ziggy, compared to TenancyUrlGenerator::$passTenantParameterToRoutes.
*
* With query string identification, this has no effect since URL::defaults() only works for route paramaters.
*/
public static bool $addTenantParameterToDefaults = true;
public function __construct(
protected Application $app,
protected UrlGenerator $originalUrlGenerator,
@ -32,12 +43,12 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
{
URL::clearResolvedInstances();
$this->useTenancyUrlGenerator();
$this->useTenancyUrlGenerator($tenant);
}
public function revert(): void
{
$this->app->bind('url', fn () => $this->originalUrlGenerator);
$this->app->extend('url', fn () => $this->originalUrlGenerator);
}
/**
@ -45,26 +56,38 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
*
* @see \Illuminate\Routing\RoutingServiceProvider registerUrlGenerator()
*/
protected function useTenancyUrlGenerator(): void
protected function useTenancyUrlGenerator(Tenant $tenant): void
{
$this->app->extend('url', function (UrlGenerator $urlGenerator, Application $app) {
$newGenerator = new TenancyUrlGenerator(
$app['router']->getRoutes(),
$urlGenerator->getRequest(),
$app['config']->get('app.asset_url'),
);
$newGenerator = new TenancyUrlGenerator(
$this->app['router']->getRoutes(),
$this->originalUrlGenerator->getRequest(),
$this->app['config']->get('app.asset_url'),
);
$newGenerator->defaults($urlGenerator->getDefaultParameters());
$defaultParameters = $this->originalUrlGenerator->getDefaultParameters();
$newGenerator->setSessionResolver(function () {
return $this->app['session'] ?? null;
});
if (static::$addTenantParameterToDefaults) {
$tenantParameterName = PathTenantResolver::tenantParameterName();
$newGenerator->setKeyResolver(function () {
return $this->app->make('config')->get('app.key');
});
$defaultParameters = array_merge($defaultParameters, [
$tenantParameterName => PathTenantResolver::tenantParameterValue($tenant),
]);
return $newGenerator;
foreach (PathTenantResolver::allowedExtraModelColumns() as $column) {
$defaultParameters["$tenantParameterName:$column"] = $tenant->getAttribute($column);
}
}
$newGenerator->defaults($defaultParameters);
$newGenerator->setSessionResolver(function () {
return $this->app['session'] ?? null;
});
$newGenerator->setKeyResolver(function () {
return $this->app->make('config')->get('app.key');
});
$this->app->extend('url', fn () => $newGenerator);
}
}

View file

@ -22,6 +22,23 @@ class CreateUserWithRLSPolicies extends Command
protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet";
/**
* Force, rather than just enable, the created RLS policies.
*
* By default, table owners bypass RLS policies. When this is enabled,
* they also need the BYPASSRLS permission. If your setup lets you create
* a user with BYPASSRLS, you may prefer leaving this on for additional
* safety. Otherwise, if you can't use BYPASSRLS, you can set this to false
* and depend on the behavior of table owners bypassing RLS automatically.
*
* This setting generally doesn't affect behavior at all with "default"
* setups, however if you have a more custom setup, with additional users
* involved (e.g. central connection user not being the same user that
* creates tables, or the created "RLS user" creating some tables) you
* should take care with how you configure this.
*/
public static bool $forceRls = true;
public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int
{
$username = config('tenancy.rls.user.username');
@ -49,14 +66,9 @@ class CreateUserWithRLSPolicies extends Command
// Enable RLS scoping on the table (without this, queries won't be scoped using RLS)
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
/**
* Force RLS scoping on the table, so that the table owner users
* don't bypass the scoping table owners bypass RLS by default.
*
* E.g. when using a custom implementation where you create tables as the RLS user,
* the queries won't be scoped for the RLS user unless we force the RLS scoping using this query.
*/
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
if (static::$forceRls) {
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
}
}
/**

View file

@ -110,7 +110,7 @@ trait DealsWithRouteContexts
foreach ($middleware as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner])) {
$innerMiddleware = Arr::wrap($middlewareGroups[$inner]);
$innerMiddleware = array_merge($innerMiddleware, Arr::wrap($middlewareGroups[$inner]));
}
}

View file

@ -56,6 +56,6 @@ trait DealsWithTenantSymlinks
/** Determine if the provided path is an existing symlink. */
protected function symlinkExists(string $link): bool
{
return file_exists($link) && is_link($link);
return is_link($link);
}
}

View file

@ -5,49 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Exception;
use Spatie\ErrorSolutions\Contracts\BaseSolution;
use Spatie\ErrorSolutions\Contracts\ProvidesSolution;
abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution
abstract class TenantCouldNotBeIdentifiedException extends Exception
{
/** Default solution title. */
protected string $solutionTitle = 'Tenant could not be identified';
/** Default solution description. */
protected string $solutionDescription = 'Are you sure this tenant exists?';
/** Set the message. */
protected function tenantCouldNotBeIdentified(string $how): static
protected function tenantCouldNotBeIdentified(string $how): void
{
$this->message = 'Tenant could not be identified ' . $how;
return $this;
}
/** Set the solution title. */
protected function title(string $solutionTitle): static
{
$this->solutionTitle = $solutionTitle;
return $this;
}
/** Set the solution description. */
protected function description(string $solutionDescription): static
{
$this->solutionDescription = $solutionDescription;
return $this;
}
/** Get the Ignition description. */
public function getSolution(): BaseSolution
{
return BaseSolution::create($this->solutionTitle)
->setSolutionDescription($this->solutionDescription)
->setDocumentationLinks([
'Tenants' => 'https://tenancyforlaravel.com/docs/v3/tenants',
'Tenant Identification' => 'https://tenancyforlaravel.com/docs/v3/tenant-identification',
]);
}
}

View file

@ -11,5 +11,5 @@ interface UniqueIdentifierGenerator
/**
* Generate a unique identifier for a model.
*/
public static function generate(Model $model): string;
public static function generate(Model $model): string|int;
}

View file

@ -10,16 +10,11 @@ trait CreatesDatabaseUsers
{
public function createDatabase(TenantWithDatabase $tenant): bool
{
parent::createDatabase($tenant);
return $this->createUser($tenant->database());
return parent::createDatabase($tenant) && $this->createUser($tenant->database());
}
public function deleteDatabase(TenantWithDatabase $tenant): bool
{
// Some DB engines require the user to be deleted before the database (e.g. Postgres)
$this->deleteUser($tenant->database());
return parent::deleteDatabase($tenant);
return $this->deleteUser($tenant->database()) && parent::deleteDatabase($tenant);
}
}

View file

@ -23,6 +23,8 @@ use Stancl\Tenancy\Events\PullingPendingTenant;
*/
trait HasPending
{
public static string $pendingSinceCast = 'timestamp';
/** Boot the trait. */
public static function bootHasPending(): void
{
@ -32,7 +34,7 @@ trait HasPending
/** Initialize the trait. */
public function initializeHasPending(): void
{
$this->casts['pending_since'] = 'timestamp';
$this->casts['pending_since'] = static::$pendingSinceCast;
}
/** Determine if the model instance is in a pending state. */

View file

@ -4,16 +4,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Tenancy;
trait InvalidatesResolverCache
{
public static function bootInvalidatesResolverCache(): void
{
static::saved(function (Tenant&Model $tenant) {
Tenancy::invalidateResolverCache($tenant);
});
static::saved(Tenancy::invalidateResolverCache(...));
static::deleting(Tenancy::invalidateResolverCache(...));
}
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver;
use Stancl\Tenancy\Tenancy;
/**
@ -15,13 +14,9 @@ trait InvalidatesTenantsResolverCache
{
public static function bootInvalidatesTenantsResolverCache(): void
{
static::saved(function (Model $model) {
foreach (Tenancy::cachedResolvers() as $resolver) {
/** @var CachedTenantResolver $resolver */
$resolver = app($resolver);
$invalidateCache = static fn (Model $model) => Tenancy::invalidateResolverCache($model->tenant);
$resolver->invalidateCache($model->tenant);
}
});
static::saved($invalidateCache);
static::deleting($invalidateCache);
}
}

View file

@ -20,7 +20,7 @@ class PendingScope implements Scope
/**
* Apply the scope to a given Eloquent query builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
* @param Builder<Model> $builder
*
* @return void
*/

View file

@ -25,6 +25,9 @@ class ImpersonationToken extends Model
{
use CentralConnection;
/** You can set this property to customize the table name */
public static string $tableName = 'tenant_user_impersonation_tokens';
protected $guarded = [];
public $timestamps = false;
@ -33,11 +36,15 @@ class ImpersonationToken extends Model
public $incrementing = false;
protected $table = 'tenant_user_impersonation_tokens';
protected $casts = [
'created_at' => 'datetime',
];
public function getTable()
{
return static::$tableName;
}
public static function booted(): void
{
static::creating(function ($model) {

View file

@ -11,8 +11,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager
public function createDatabase(TenantWithDatabase $tenant): bool
{
$database = $tenant->database()->getName();
$charset = $this->connection()->getConfig('charset');
$collation = $this->connection()->getConfig('collation'); // todo check why these are not used
return $this->connection()->statement("CREATE DATABASE [{$database}]");
}

View file

@ -32,6 +32,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
// Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
// while the RLS user should STILL get access to those tables
foreach ($tables as $table) {
$tableName = $table->table_name;

View file

@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Enums;
enum RouteMode
/**
* Note: The backing values are not part of the public API and are subject to change.
*/
enum RouteMode: int
{
case TENANT;
case CENTRAL;
case UNIVERSAL;
case CENTRAL = 0b01;
case TENANT = 0b10;
case UNIVERSAL = 0b11;
}

View file

@ -7,15 +7,11 @@ namespace Stancl\Tenancy\Events\Contracts;
use Illuminate\Queue\SerializesModels;
use Stancl\Tenancy\Contracts\Tenant;
abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here
abstract class TenantEvent
{
use SerializesModels;
/** @var Tenant */
public $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
public function __construct(
public Tenant $tenant,
) {}
}

View file

@ -10,9 +10,6 @@ class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedExce
{
public function __construct(int|string $tenant_id)
{
$this
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)")
->title('Tenant could not be identified on this route because the used column is not whitelisted.')
->description('Please add the column to the list of allowed columns in the PathTenantResolver config.');
$this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)");
}
}

View file

@ -2,8 +2,6 @@
declare(strict_types=1);
// todo perhaps create Identification namespace
namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
@ -12,9 +10,6 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified
{
public function __construct(int|string $tenant_id)
{
$this
->tenantCouldNotBeIdentified("by tenant key: $tenant_id")
->title('Tenant could not be identified with that key')
->description('Are you sure the key is correct and the tenant exists?');
$this->tenantCouldNotBeIdentified("by tenant key: $tenant_id");
}
}

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi
{
public function __construct(int|string $tenant_id)
{
$this
->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id")
->title('Tenant could not be identified on this path')
->description('Did you forget to create a tenant for this path?');
$this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id");
}
}

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByRequestDataException extends TenantCouldNotBeI
{
public function __construct(mixed $payload)
{
$this
->tenantCouldNotBeIdentified("by request data with payload: $payload")
->title('Tenant could not be identified using this request data')
->description('Did you forget to create a tenant with this id?');
$this->tenantCouldNotBeIdentified("by request data with payload: $payload");
}
}

View file

@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedOnDomainException extends TenantCouldNotBeIdenti
{
public function __construct(string $domain)
{
$this
->tenantCouldNotBeIdentified("on domain $domain")
->title('Tenant could not be identified on this domain')
->description('Did you forget to create a tenant for this domain?');
$this->tenantCouldNotBeIdentified("on domain $domain");
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Stancl\Tenancy\Contracts\Feature;
@ -18,8 +19,8 @@ class UserImpersonation implements Feature
public function bootstrap(Tenancy $tenancy): void
{
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): ImpersonationToken {
return ImpersonationToken::create([
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
return UserImpersonation::modelClass()::create([
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId,
'redirect_url' => $redirectUrl,
@ -30,10 +31,15 @@ class UserImpersonation implements Feature
}
/** Impersonate a user and get an HTTP redirect response. */
public static function makeResponse(#[\SensitiveParameter] string|ImpersonationToken $token, ?int $ttl = null): RedirectResponse
public static function makeResponse(#[\SensitiveParameter] string|Model $token, ?int $ttl = null): RedirectResponse
{
/** @var ImpersonationToken $token */
$token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token);
/**
* The model does NOT have to extend ImpersonationToken, but usually it WILL be a child
* of ImpersonationToken and this makes it clear to phpstan that the model has a redirect_url property.
*
* @var ImpersonationToken $token
*/
$token = $token instanceof Model ? $token : static::modelClass()::findOrFail($token);
$ttl ??= static::$ttl;
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
@ -54,6 +60,12 @@ class UserImpersonation implements Feature
return redirect($token->redirect_url);
}
/** @return class-string<Model> */
public static function modelClass(): string
{
return config('tenancy.models.impersonation_token');
}
public static function isImpersonating(): bool
{
return session()->has('tenancy_impersonating');
@ -62,7 +74,7 @@ class UserImpersonation implements Feature
/**
* Logout from the current domain and forget impersonation session.
*/
public static function leave(): void // todo@name possibly rename
public static function stopImpersonating(): void
{
auth()->logout();

View file

@ -8,6 +8,8 @@ use Illuminate\Routing\Events\RouteMatched;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo@earlyIdReview
/**
* Remove the tenant parameter from the matched route when path identification is used globally.
*

View file

@ -10,6 +10,13 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma
{
public function getDomain(Request $request): string
{
return $request->header('Origin', '');
if ($origin = $request->header('Origin', '')) {
$host = parse_url($origin, PHP_URL_HOST) ?? $origin;
assert(is_string($host) && strlen($host) > 0);
return $host;
}
return '';
}
}

View file

@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
{
use UsableWithEarlyIdentification;
public static string $header = 'X-Tenant';
public static string $cookie = 'tenant';
public static string $queryParameter = 'tenant';
public static ?Closure $onFail = null;
public static bool $requireCookieEncryption = false;
@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
protected function getPayload(Request $request): string|null
{
if (static::$header && $request->hasHeader(static::$header)) {
$payload = $request->header(static::$header);
} elseif (
static::$queryParameter &&
$request->has(static::$queryParameter)
) {
$payload = $request->get(static::$queryParameter);
} elseif (static::$cookie && $request->hasCookie(static::$cookie)) {
$payload = $request->cookie(static::$cookie);
$headerName = RequestDataTenantResolver::headerName();
$queryParameterName = RequestDataTenantResolver::queryParameterName();
$cookieName = RequestDataTenantResolver::cookieName();
if ($headerName && $request->hasHeader($headerName)) {
$payload = $request->header($headerName);
} elseif ($queryParameterName && $request->has($queryParameterName)) {
$payload = $request->get($queryParameterName);
} elseif ($cookieName && $request->hasCookie($cookieName)) {
$payload = $request->cookie($cookieName);
if ($payload && is_string($payload)) {
$payload = $this->getTenantFromCookie($payload);
$payload = $this->getTenantFromCookie($cookieName, $payload);
}
} else {
$payload = null;
@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
return (bool) $this->getPayload($request);
}
protected function getTenantFromCookie(string $cookie): string|null
protected function getTenantFromCookie(string $cookieName, string $cookieValue): string|null
{
// If the cookie looks like it's encrypted, we try decrypting it
if (str_starts_with($cookie, 'eyJpdiI')) {
if (str_starts_with($cookieValue, 'eyJpdiI')) {
try {
$json = base64_decode($cookie);
$json = base64_decode($cookieValue);
$data = json_decode($json, true);
if (
@ -100,9 +98,9 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
) {
// We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just
// return null and the cookie payload would get skipped.
$cookie = CookieValuePrefix::validate(
static::$cookie,
Crypt::decryptString($cookie),
$cookieValue = CookieValuePrefix::validate(
$cookieName,
Crypt::decryptString($cookieValue),
Crypt::getAllKeys()
);
}
@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware
return null;
}
return $cookie;
return $cookieValue;
}
}

View file

@ -68,7 +68,7 @@ class PreventAccessFromUnwantedDomains
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
}
// todo@samuel
// todo@samuel technically not an identification middleware but probably ok to keep this here
public function requestHasTenant(Request $request): bool
{
return false;

View file

@ -19,6 +19,10 @@ class ScopeSessions
public function handle(Request $request, Closure $next): mixed
{
if (! tenancy()->initialized) {
if (tenancy()->routeIsUniversal(tenancy()->getRoute($request))) {
return $next($request);
}
throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed');
}

View file

@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides;
use Illuminate\Cache\CacheManager as BaseCacheManager;
// todo@move move to Cache namespace?
class CacheManager extends BaseCacheManager
{
/**

View file

@ -9,43 +9,100 @@ use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
/**
* This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled.
*
* TenancyUrlGenerator does two extra things:
* 1. Autofill the {tenant} parameter in the tenant context with the current tenant if $passTenantParameterToRoutes is enabled (enabled by default)
* 2. Prepend the route name with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled (disabled by default)
* TenancyUrlGenerator does a few extra things:
* - Autofills the tenant parameter in the tenant context with the current tenant.
* This is done either by:
* - URL::defaults() -- if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is enabled.
* This generally has the best support since tools like e.g. Ziggy read defaults().
* - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled
* This is a more universal solution since it supports both path identification and query parameter identification.
*
* Both of these can be skipped by passing the $bypassParameter (`['central' => true]` by default)
* - Prepends route names passed to route() and URL::temporarySignedRoute()
* with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled.
* This is primarily useful when using route cloning with path identification.
*
* To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default).
*/
class TenancyUrlGenerator extends UrlGenerator
{
/**
* Parameter which bypasses the behavior modification of route() and temporarySignedRoute().
* Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute().
*
* E.g. route('tenant') => app.test/{tenant}/tenant (or app.test/tenant?tenant=tenantKey if the route doesn't accept the tenant parameter)
* route('tenant', [$bypassParameter => true]) => app.test/tenant.
* For example, in tenant context:
* Route::get('/', ...)->name('home');
* // query string identification
* Route::get('/tenant', ...)->middleware(InitializeTenancyByRequestData::class)->name('tenant.home');
* - route('home') => app.test/tenant?tenant=tenantKey
* - route('home', [$bypassParameter => true]) => app.test/
* - route('tenant.home', [$bypassParameter => true]) => app.test/tenant -- no query string added
*
* Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though
* it doesn't matter since it doesn't pass any extra parameters when not needed.
*
* @see UrlGeneratorBootstrapper
*/
public static string $bypassParameter = 'central';
/**
* Determine if the route names passed to `route()` or `temporarySignedRoute()`
* should get prefixed with the tenant route name prefix.
* Should route names passed to route() or temporarySignedRoute()
* get prefixed with the tenant route name prefix.
*
* This is useful when using path identification with packages that generate URLs,
* like Jetstream, so that you don't have to manually prefix route names passed to each route() call.
* This is useful when using e.g. path identification with third-party packages
* where you don't have control over all route() calls or don't want to change
* too many files. Often this will be when using route cloning.
*/
public static bool $prefixRouteNames = false;
/**
* Determine if the tenant parameter should get passed
* to the links generated by `route()` or `temporarySignedRoute()` whenever available
* (enabled by default works with both path and query string identification).
* Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
*
* With path identification, you can disable this and use URL::defaults() instead (as an alternative solution).
* This is useful with path or query parameter identification. The former can be handled
* more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults.
*
* @see UrlGeneratorBootstrapper
*/
public static bool $passTenantParameterToRoutes = true;
public static bool $passTenantParameterToRoutes = false;
/**
* Route name overrides.
*
* Note: This behavior can be bypassed using $bypassParameter just like
* $prefixRouteNames and $passTenantParameterToRoutes.
*
* Example from a Jetstream integration:
* [
* 'profile.show' => 'tenant.profile.show',
* 'two-factor.login' => 'tenant.two-factor.login',
* ]
*
* In the tenant context:
* - `route('profile.show')` will return a URL as if you called `route('tenant.profile.show')`.
* - `route('profile.show', ['central' => true])` will return a URL as if you called `route('profile.show')`.
*/
public static array $overrides = [];
/**
* Follow the query_parameter config instead of the tenant_parameter_name (path identification) config.
*
* This only has an effect when:
* - $passTenantParameterToRoutes is enabled, and
* - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver.
*
* In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'),
* the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'.
*
* This is enabled by default because typically you will not need $passTenantParameterToRoutes with path identification.
* UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification.
*
* On the other hand, when using request data identification (specifically query string) you WILL need to pass the parameter
* directly to route() calls, therefore you would use $passTenantParameterToRoutes to avoid having to do that manually.
*/
public static bool $passQueryParameter = true;
/**
* Override the route() method so that the route name gets prefixed
@ -99,7 +156,7 @@ class TenancyUrlGenerator extends UrlGenerator
protected function prepareRouteInputs(string $name, array $parameters): array
{
if (! $this->routeBehaviorModificationBypassed($parameters)) {
$name = $this->prefixRouteName($name);
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
$parameters = $this->addTenantParameter($parameters);
}
@ -124,10 +181,26 @@ class TenancyUrlGenerator extends UrlGenerator
}
/**
* If `tenant()` isn't null, add tenant paramter to the passed parameters.
* If `tenant()` isn't null, add the tenant parameter to the passed parameters.
*/
protected function addTenantParameter(array $parameters): array
{
return tenant() && static::$passTenantParameterToRoutes ? array_merge($parameters, [PathTenantResolver::tenantParameterName() => tenant()->getTenantKey()]) : $parameters;
if (tenant() && static::$passTenantParameterToRoutes) {
if (static::$passQueryParameter) {
$queryParameterName = RequestDataTenantResolver::queryParameterName();
if ($queryParameterName !== null) {
return array_merge($parameters, [$queryParameterName => RequestDataTenantResolver::payloadValue(tenant())]);
}
}
return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]);
} else {
return $parameters;
}
}
protected function routeNameOverride(string $name): string|null
{
return static::$overrides[$name] ?? null;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\Exceptions;
use Exception;
class RLSCommentConstraintException extends Exception
{
public function __construct(string|null $message = null)
{
parent::__construct($message ?? 'Invalid comment constraint.');
}
}

View file

@ -5,22 +5,90 @@ declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations
/**
* Generates queries for creating RLS policies
* for tables related to the tenants table.
*
* Usage:
* // Generate queries for creating RLS policies.
* // The queries will be returned in this format:
* // [
* // <<<SQL
* // CREATE POLICY authors_rls_policy ON authors USING (
* // tenant_id::text = current_setting('my.current_tenant')
* // );
* // SQL,
* // <<<SQL
* // CREATE POLICY posts_rls_policy ON posts USING (
* // author_id IN (
* // SELECT id
* // FROM authors
* // WHERE tenant_id::text = current_setting('my.current_tenant')
* // )
* // );
* // SQL,
* // ]
* // This is used In the CreateUserWithRLSPolicies command.
* // Calls shortestPaths() internally to generate paths, then generates queries for each path.
* $queries = app(TableRLSManager::class)->generateQueries();
*
* // Generate the shortest path from table X to the tenants table.
* // Calls shortestPathToTenantsTable() recursively.
* // The paths will be returned in this format:
* // [
* // 'foo_table' => [...$stepsLeadingToTenantsTable],
* // 'bar_table' => [
* // [
* // 'localColumn' => 'post_id',
* // 'foreignTable' => 'posts',
* // 'foreignColumn' => 'id'
* // ],
* // [
* // 'localColumn' => 'tenant_id',
* // 'foreignTable' => 'tenants',
* // 'foreignColumn' => 'id'
* // ],
* // ],
* // This is used in the CreateUserWithRLSPolicies command.
* $shortestPath = app(TableRLSManager::class)->shortestPaths();
*
* generateQueries() and shortestPaths() methods are the only public methods of this class.
* The rest of the methods are protected, and only used internally.
* To see how they're structured and how they work, you can check their annotations.
*/
class TableRLSManager implements RLSPolicyManager
{
/**
* When true, all valid constraints are considered while generating paths for RLS policies,
* unless explicitly marked with a 'no-rls' comment.
*
* When false, only columns explicitly marked with 'rls' or 'rls table.column' comments are considered.
*/
public static bool $scopeByDefault = true;
public function __construct(
protected DatabaseManager $database
) {}
public function generateQueries(array $trees = []): array
/**
* Generate queries that will be executed by the tenants:rls command
* for creating RLS policies for all tables related to the tenants table
* or for a passed array of paths.
*
* The passed paths should be formatted like this:
* [
* 'table_name' => [...$stepsLeadingToTenantsTable]
* ]
*/
public function generateQueries(array $paths = []): array
{
$queries = [];
foreach ($trees ?: $this->shortestPaths() as $table => $path) {
foreach ($paths ?: $this->shortestPaths() as $table => $path) {
$queries[$table] = $this->generateQuery($table, $path);
}
@ -28,184 +96,415 @@ class TableRLSManager implements RLSPolicyManager
}
/**
* Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]).
* Generate shortest paths from each table to the tenants table,
* structured like ['table_foo' => $shortestPathFromFoo, 'table_bar' => $shortestPathFromBar].
*
* For example:
*
* 'posts' => [
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* ],
* 'comments' => [
* [
* 'foreignKey' => 'post_id',
* 'localColumn' => 'post_id',
* 'foreignTable' => 'posts',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* 'foreignColumn' => 'id'
* ],
* ],
*
* @throws RecursiveRelationshipException When tables have recursive relationships and no other valid paths
* @throws RLSCommentConstraintException When comment constraints are malformed
*/
public function shortestPaths(array $trees = []): array
public function shortestPaths(): array
{
$reducedTrees = [];
$shortestPaths = [];
foreach ($trees ?: $this->generateTrees() as $table => $tree) {
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree);
foreach ($this->getTableNames() as $tableName) {
// Generate the shortest path from table named $tableName to the tenants table
$shortestPath = $this->shortestPathToTenantsTable($tableName);
if ($this->isValidPath($shortestPath)) {
// Format path steps to a more readable format (keep only the needed data)
$shortestPaths[$tableName] = array_map(fn (array $step) => [
'localColumn' => $step['localColumn'],
'foreignTable' => $step['foreignTable'],
'foreignColumn' => $step['foreignColumn'],
], $shortestPath['steps']);
}
// No valid path found. The shortest path either
// doesn't lead to the tenants table (ignore),
// or leads through a recursive relationship (throw an exception).
if ($shortestPath['recursive_relationship']) {
throw new RecursiveRelationshipException(
"Table '{$tableName}' has recursive relationships with no other valid paths to the tenants table."
);
}
}
return $reducedTrees;
return $shortestPaths;
}
/**
* Generate trees of paths that lead to the tenants table
* for the foreign keys of all tables only the paths that lead to the tenants table are included.
* Create a path array with the given parameters.
* This method serves as a 'single source of truth' for the path array structure.
*
* Also unset the 'comment' key from the retrieved path steps.
* The 'steps' key contains the path steps returned by shortestPaths().
* The 'dead_end' and 'recursive_relationship' keys are just internal metadata.
*
* @param bool $deadEnd Whether the path is a dead end (no valid constraints leading to tenants table)
* @param bool $recursive Whether the path has recursive relationships
* @param array $steps Steps to the tenants table, each step being a formatted constraint
*/
public function generateTrees(): array
protected function buildPath(bool $deadEnd = false, bool $recursive = false, array $steps = []): array
{
$trees = [];
$builder = $this->database->getSchemaBuilder();
// We loop through each table in the database
foreach ($builder->getTableListing() as $table) {
// For each table, we get a list of all foreign key columns
$foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) {
return $this->formatForeignKey($foreign, $table);
});
// We loop through each foreign key column and find
// all possible paths that lead to the tenants table
foreach ($foreignKeys as $foreign) {
$paths = [];
$this->generatePaths($table, $foreign, $paths);
foreach ($paths as &$path) {
foreach ($path as &$step) {
unset($step['comment']);
}
}
if (count($paths)) {
$trees[$table][$foreign['foreignKey']] = $paths;
}
}
}
return $trees;
}
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
{
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
throw new RecursiveRelationshipException;
}
$currentPath[] = $foreign;
if ($foreign['foreignTable'] === tenancy()->model()->getTable()) {
$comments = array_column($currentPath, 'comment');
$pathCanUseRls = static::$scopeByDefault ?
! in_array('no-rls', $comments) :
! in_array('no-rls', $comments) && ! in_array(null, $comments);
if ($pathCanUseRls) {
// If the foreign table is the tenants table, add the current path to $paths
$paths[] = $currentPath;
}
} else {
// If not, recursively generate paths for the foreign table
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
}
}
}
/** Get tree's non-nullable paths. */
protected function filterNonNullablePaths(array $tree): array
{
$nonNullablePaths = [];
foreach ($tree as $foreignKey => $paths) {
foreach ($paths as $path) {
$pathIsNullable = false;
foreach ($path as $step) {
if ($step['nullable']) {
$pathIsNullable = true;
break;
}
}
if (! $pathIsNullable) {
$nonNullablePaths[$foreignKey][] = $path;
}
}
}
return $nonNullablePaths;
}
/** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */
protected function findShortestPath(array $tree): array
{
$shortestPath = [];
foreach ($tree as $pathsForForeignKey) {
foreach ($pathsForForeignKey as $path) {
if (empty($shortestPath) || count($shortestPath) > count($path)) {
$shortestPath = $path;
foreach ($shortestPath as &$step) {
unset($step['nullable']);
}
}
}
}
return $shortestPath;
return [
'dead_end' => $deadEnd,
'recursive_relationship' => $recursive,
'steps' => $steps,
];
}
/**
* Formats the foreign key array retrieved by Postgres to a more readable format.
* Formats the retrieved constraint to a more readable format.
*
* Also provides information about whether the foreign key is nullable,
* and the foreign key column comment. These additional details are removed
* from the foreign keys/path steps before returning the final shortest paths.
* Also provides internal metadata about
* - the constraint's nullability (the 'nullable' key),
* - the constraint's comment
*
* The 'comment' key gets deleted while generating the full trees (in generateTrees()),
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
* These internal details are then omitted
* from the constraints (or the "path steps")
* before returning the shortest paths in shortestPath().
*
* [
* 'foreignKey' => 'tenant_id',
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id',
* 'comment' => 'no-rls', // Foreign key comment used to explicitly enable/disable RLS
* 'nullable' => false, // Whether the foreign key is nullable
* 'foreignColumn' => 'id',
* 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata)
* 'nullable' => false, // Used to determine if the constraint is nullable (internal metadata)
* ].
*/
protected function formatForeignKey(array $foreignKey, string $table): array
protected function formatForeignKey(array $constraint, string $table): array
{
// $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table)
assert(count($constraint['columns']) === 1);
$localColumn = $constraint['columns'][0];
$comment = collect($this->database->getSchemaBuilder()->getColumns($table))
->filter(fn ($column) => $column['name'] === $localColumn)
->first()['comment'] ?? null;
$columnIsNullable = $this->database->selectOne(
'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
[$table, $localColumn]
)->is_nullable === 'YES';
assert(count($constraint['foreign_columns']) === 1);
return $this->formatConstraint(
localColumn: $localColumn,
foreignTable: $constraint['foreign_table'],
foreignColumn: $constraint['foreign_columns'][0],
comment: $comment,
nullable: $columnIsNullable
);
}
/** Single source of truth for our constraint format. */
protected function formatConstraint(
string $localColumn,
string $foreignTable,
string $foreignColumn,
string|null $comment,
bool $nullable
): array {
return [
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0],
'foreignTable' => $foreignKey['foreign_table'],
'foreignId' => $foreignKey['foreign_columns'][0],
// Deleted in generateTrees()
'comment' => $this->getComment($table, $foreignKeyName),
// Deleted in shortestPaths()
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
'localColumn' => $localColumn,
'foreignTable' => $foreignTable,
'foreignColumn' => $foreignColumn,
// Internal metadata omitted in shortestPaths()
'comment' => $comment,
'nullable' => $nullable,
];
}
/**
* Recursively traverse a table's constraints to find
* the shortest path to the tenants table.
*
* The shortest paths are cached in $cachedPaths to avoid
* generating them for already visited tables repeatedly.
*
* @param string $table The table to find a path from
* @param array &$cachedPaths Reference to array where discovered shortest paths are cached (including dead ends)
* @param array $visitedTables Already visited tables (used for detecting recursive relationships)
* @return array Paths with 'steps' (arrays of formatted constraints), 'dead_end' flag (bool), and 'recursive_relationship' flag (bool).
*/
protected function shortestPathToTenantsTable(
string $table,
array &$cachedPaths = [],
array $visitedTables = []
): array {
// Return the shortest path for this table if it was already found and cached
if (isset($cachedPaths[$table])) {
return $cachedPaths[$table];
}
// Reached tenants table (last step)
if ($table === tenancy()->model()->getTable()) {
// This pretty much just means we set $cachedPaths['tenants'] to an
// empty path. The significance of an empty path is that this class
// considers it to mean "you are at the tenants table".
$cachedPaths[$table] = $this->buildPath();
return $cachedPaths[$table];
}
$constraints = $this->getConstraints($table);
if (empty($constraints)) {
// Dead end
$cachedPaths[$table] = $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Find the optimal path from a table to the tenants table.
*
* Gather table's constraints (both foreign key constraints and comment constraints)
* and recursively find shortest paths through each constraint (non-nullable paths are preferred for reliability).
*
* Handle recursive relationships by skipping paths that would create loops.
* If there's no valid path in the end, and the table has recursive relationships,
* an appropriate exception is thrown.
*
* At the end, it returns the shortest non-nullable path if available,
* fall back to the overall shortest path.
*/
$visitedTables = [...$visitedTables, $table];
$shortestPath = [];
$hasRecursiveRelationships = false;
$hasValidPaths = false;
foreach ($constraints as $constraint) {
$foreignTable = $constraint['foreignTable'];
// Skip constraints that would create loops
if (in_array($foreignTable, $visitedTables)) {
$hasRecursiveRelationships = true;
continue;
}
// Recursive call
$pathThroughConstraint = $this->shortestPathToTenantsTable(
$foreignTable,
$cachedPaths,
$visitedTables
);
if ($pathThroughConstraint['recursive_relationship']) {
$hasRecursiveRelationships = true;
continue;
}
// Skip dead ends
if ($pathThroughConstraint['dead_end']) {
continue;
}
$hasValidPaths = true;
$path = $this->buildPath(steps: array_merge([$constraint], $pathThroughConstraint['steps']));
if ($this->isPathPreferable($path, $shortestPath)) {
$shortestPath = $path;
}
}
// Handle tables with only recursive relationships
if ($hasRecursiveRelationships && ! $hasValidPaths) {
// Don't cache paths that cause recursion - return right away.
// This allows tables with recursive relationships to be processed again.
// Example:
// - posts table has highlighted_comment_id that leads to the comments table
// - comments table has recursive_post_id that leads to the posts table (recursive relationship),
// - comments table also has tenant_id which leads to the tenants table (a valid path).
// If the recursive path got cached first, the path leading directly through tenants would never be found.
return $this->buildPath(recursive: true);
}
$cachedPaths[$table] = $shortestPath ?: $this->buildPath(deadEnd: true);
return $cachedPaths[$table];
}
/**
* Get all valid relationship constraints for a table. The constraints are also formatted.
* Combines both standard foreign key constraints and comment constraints.
*
* The schema builder retrieves foreign keys in the following format:
* [
* 'name' => 'posts_tenant_id_foreign',
* 'columns' => ['tenant_id'],
* 'foreign_table' => 'tenants',
* 'foreign_columns' => ['id'],
* ...
* ]
*
* We format that into a more readable format using formatForeignKey(),
* and that method uses formatConstraint(), which serves as a single source of truth
* for our constraint formatting. A formatted constraint looks like this:
* [
* 'localColumn' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignColumn' => 'id',
* 'comment' => 'no-rls',
* 'nullable' => false
* ]
*
* The comment constraints are retrieved using getFormattedCommentConstraints().
* These constraints are formatted in the method itself.
*/
protected function getConstraints(string $table): array
{
$formattedConstraints = array_merge(
array_map(
fn ($schemaStructure) => $this->formatForeignKey($schemaStructure, $table),
$this->database->getSchemaBuilder()->getForeignKeys($table)
),
$this->getFormattedCommentConstraints($table)
);
$validConstraints = [];
foreach ($formattedConstraints as $constraint) {
if (! $this->shouldSkipPathLeadingThroughConstraint($constraint)) {
$validConstraints[] = $constraint;
}
}
return $validConstraints;
}
/**
* Determine if a path leading through the passed constraint
* should be excluded from choosing the shortest path
* based on the constraint's comment.
*
* If $scopeByDefault is true, only skip paths leading through constraints flagged with the 'no-rls' comment.
* If $scopeByDefault is false, skip paths leading through any constraint, unless the key has explicit 'rls' or 'rls table.column' comments.
*
* @param array $constraint Formatted constraint
*/
protected function shouldSkipPathLeadingThroughConstraint(array $constraint): bool
{
$comment = $constraint['comment'] ?? null;
// Always skip constraints with the 'no-rls' comment
if ($comment === 'no-rls') {
return true;
}
if (static::$scopeByDefault) {
return false;
}
// When $scopeByDefault is false, skip every constraint
// with a comment that doesn't start with 'rls'.
if (! is_string($comment)) {
return true;
}
// Explicit scoping
if ($comment === 'rls') {
return false;
}
// Comment constraint
if (Str::startsWith($comment, 'rls ')) {
return false;
}
return true;
}
/**
* Retrieve a table's comment constraints.
*
* Comment constraints are columns with comments
* structured like "rls <foreign_table>.<foreign_column>".
*
* Returns an array of formatted comment constraints (check formatConstraint() to see the format).
*/
protected function getFormattedCommentConstraints(string $tableName): array
{
$commentConstraints = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) {
return (isset($column['comment']) && is_string($column['comment']))
&& Str::startsWith($column['comment'], 'rls ');
});
// Validate and format the comment constraints
$commentConstraints = array_map(
fn ($commentConstraint) => $this->parseCommentConstraint($commentConstraint, $tableName),
$commentConstraints
);
return $commentConstraints;
}
/**
* Parse and validate a comment constraint.
*
* This method validates that the table and column referenced
* in the comment exist, formats and returns the constraint.
*
* @throws RLSCommentConstraintException When comment format is invalid or references don't exist
*/
protected function parseCommentConstraint(array $commentConstraint, string $tableName): array
{
$comment = $commentConstraint['comment'];
$columnName = $commentConstraint['name'];
$builder = $this->database->getSchemaBuilder();
$constraint = explode('.', Str::after($comment, 'rls '));
// Validate comment constraint format
if (count($constraint) !== 2 || empty($constraint[0]) || empty($constraint[1])) {
throw new RLSCommentConstraintException("Malformed comment constraint on {$tableName}.{$columnName}: '{$comment}'");
}
$foreignTable = $constraint[0];
$foreignColumn = $constraint[1];
// Validate table existence
if (! $builder->hasTable($foreignTable)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent table '{$foreignTable}'");
}
// Validate column existence
if (! $builder->hasColumn($foreignTable, $foreignColumn)) {
throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent column '{$foreignTable}.{$foreignColumn}'");
}
// Return the formatted constraint
return $this->formatConstraint(
localColumn: $commentConstraint['name'],
foreignTable: $foreignTable,
foreignColumn: $foreignColumn,
comment: $commentConstraint['comment'],
nullable: $commentConstraint['nullable']
);
}
/** Generates a query that creates a row-level security policy for the passed table. */
protected function generateQuery(string $table, array $path): string
{
@ -214,9 +513,9 @@ class TableRLSManager implements RLSPolicyManager
$sessionTenantKey = config('tenancy.rls.session_variable_name');
foreach ($path as $index => $relation) {
$column = $relation['foreignKey'];
$column = $relation['localColumn'];
$table = $relation['foreignTable'];
$foreignKey = $relation['foreignId'];
$foreignKey = $relation['foreignColumn'];
$indentation = str_repeat(' ', ($index + 1) * 4);
@ -249,12 +548,65 @@ class TableRLSManager implements RLSPolicyManager
return $query;
}
protected function getComment(string $tableName, string $columnName): string|null
/** Returns unprefixed table names. */
protected function getTableNames(): array
{
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName))
->filter(fn ($column) => $column['name'] === $columnName)
->first();
$builder = $this->database->getSchemaBuilder();
$tables = [];
return $column['comment'] ?? null;
foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) {
// E.g. "public.table_name" -> "table_name"
$tables[] = str($table)->afterLast('.')->toString();
}
return $tables;
}
/**
* Check if discovered path is valid for RLS policy generation.
*
* A valid path:
* - leads to tenants table (isn't dead end)
* - has at least one step (the tenants table itself will have no steps)
*/
protected function isValidPath(array $path): bool
{
return ! $path['dead_end'] && ! empty($path['steps']);
}
/**
* Determine if the passed path is preferred to the current shortest path.
*
* Non-nullable paths are preferred to nullable paths.
* From paths of the same nullability, the shorter will be preferred.
*/
protected function isPathPreferable(array $path, array $shortestPath): bool
{
if (! $shortestPath) {
return true;
}
$pathIsNullable = $this->isPathNullable($path['steps']);
$shortestPathIsNullable = $this->isPathNullable($shortestPath['steps']);
// Prefer non-nullable
if ($pathIsNullable !== $shortestPathIsNullable) {
return ! $pathIsNullable;
}
// Prefer shorter
return count($path['steps']) < count($shortestPath['steps']);
}
/** Determine if any step in the path is nullable. */
protected function isPathNullable(array $path): bool
{
foreach ($path as $step) {
if ($step['nullable']) {
return true;
}
}
return false;
}
}

View file

@ -73,7 +73,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
public static function tenantRouteNamePrefix(): string
{
return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.';
return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? 'tenant.';
}
public static function tenantModelColumn(): string
@ -81,6 +81,11 @@ class PathTenantResolver extends Contracts\CachedTenantResolver
return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName();
}
public static function tenantParameterValue(Tenant $tenant): string
{
return $tenant->getAttribute(static::tenantModelColumn());
}
/** @return string[] */
public static function allowedExtraModelColumns(): array
{

View file

@ -20,7 +20,9 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
{
$payload = (string) $args[0];
if ($payload && $tenant = tenancy()->find($payload, withRelations: true)) {
$column = static::tenantModelColumn();
if ($payload && $tenant = tenancy()->find($payload, $column, withRelations: true)) {
return $tenant;
}
@ -29,8 +31,43 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver
public function getPossibleCacheKeys(Tenant&Model $tenant): array
{
// todo@tests
return [
$this->formatCacheKey($tenant->getTenantKey()),
$this->formatCacheKey(static::payloadValue($tenant)),
];
}
public static function payloadValue(Tenant $tenant): string
{
return $tenant->getAttribute(static::tenantModelColumn());
}
public static function tenantModelColumn(): string
{
return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName();
}
/**
* Returns the name of the header used for identification, or null if header identification is disabled.
*/
public static function headerName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.header');
}
/**
* Returns the name of the query parameter used for identification, or null if query parameter identification is disabled.
*/
public static function queryParameterName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.query_parameter');
}
/**
* Returns the name of the cookie used for identification, or null if cookie identification is disabled.
*/
public static function cookieName(): string|null
{
return config('tenancy.identification.resolvers.' . static::class . '.cookie');
}
}

View file

@ -9,8 +9,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
// todo@move move all resource syncing-related things to a separate namespace?
/**
* @property-read TenantWithDatabase[]|Collection<int, TenantWithDatabase&Model> $tenants
*/

View file

@ -77,18 +77,21 @@ class Tenancy
public function run(Tenant $tenant, Closure $callback): mixed
{
$originalTenant = $this->tenant;
$result = null;
$this->initialize($tenant);
$result = $callback($tenant);
try {
$this->initialize($tenant);
$result = $callback($tenant);
} finally {
if ($result instanceof PendingDispatch) { // #1277
$result = null;
}
if ($result instanceof PendingDispatch) { // #1277
$result = null;
}
if ($originalTenant) {
$this->initialize($originalTenant);
} else {
$this->end();
if ($originalTenant) {
$this->initialize($originalTenant);
} else {
$this->end();
}
}
return $result;
@ -204,8 +207,10 @@ class Tenancy
// Wrap string in array
$tenants = is_string($tenants) ? [$tenants] : $tenants;
// Use all tenants if $tenants is falsy
$tenants = $tenants ?: $this->model()->cursor(); // todo@phpstan phpstan thinks this isn't needed, but tests fail without it
// If $tenants is falsy by this point (e.g. an empty array) there's no work to be done
if (! $tenants) {
return;
}
$originalTenant = $this->tenant;

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy;
use Closure;
use Illuminate\Cache\CacheManager;
use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Routing\Events\RouteMatched;
@ -18,9 +19,15 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class TenancyServiceProvider extends ServiceProvider
{
public static Closure|null $configure = null;
/* Register services. */
public function register(): void
{
if (static::$configure) {
(static::$configure)();
}
$this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy');
$this->app->singleton(Database\DatabaseManager::class);

View file

@ -18,7 +18,7 @@ class RandomHexGenerator implements UniqueIdentifierGenerator
{
public static int $bytes = 6;
public static function generate(Model $model): string
public static function generate(Model $model): string|int
{
return bin2hex(random_bytes(static::$bytes));
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/**
* Generates a cryptographically secure random integer for the tenant key.
*/
class RandomIntGenerator implements UniqueIdentifierGenerator
{
public static int $min = 0;
public static int $max = PHP_INT_MAX;
public static function generate(Model $model): string|int
{
return random_int(static::$min, static::$max);
}
}

View file

@ -18,7 +18,7 @@ class RandomStringGenerator implements UniqueIdentifierGenerator
{
public static int $length = 8;
public static function generate(Model $model): string
public static function generate(Model $model): string|int
{
return Str::random(static::$length);
}

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 UUID for the tenant key.
*/
class ULIDGenerator implements UniqueIdentifierGenerator
{
public static function generate(Model $model): string|int
{
return Str::ulid()->toString();
}
}

View file

@ -13,7 +13,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
*/
class UUIDGenerator implements UniqueIdentifierGenerator
{
public static function generate(Model $model): string
public static function generate(Model $model): string|int
{
return Uuid::uuid4()->toString();
}

View file

@ -30,6 +30,7 @@ if (! function_exists('tenant')) {
return app(Tenant::class);
}
// @phpstan-ignore-next-line nullsafe.neverNull
return app(Tenant::class)?->getAttribute($key);
}
}