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:
commit
2cfa8831a3
113 changed files with 3035 additions and 1373 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
146
src/Bootstrappers/PersistentQueueTenancyBootstrapper.php
Normal file
146
src/Bootstrappers/PersistentQueueTenancyBootstrapper.php
Normal 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 {}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(...));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}]");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides;
|
|||
|
||||
use Illuminate\Cache\CacheManager as BaseCacheManager;
|
||||
|
||||
// todo@move move to Cache namespace?
|
||||
|
||||
class CacheManager extends BaseCacheManager
|
||||
{
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/RLS/Exceptions/RLSCommentConstraintException.php
Normal file
15
src/RLS/Exceptions/RLSCommentConstraintException.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
22
src/UniqueIdentifierGenerators/RandomIntGenerator.php
Normal file
22
src/UniqueIdentifierGenerators/RandomIntGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
20
src/UniqueIdentifierGenerators/ULIDGenerator.php
Normal file
20
src/UniqueIdentifierGenerators/ULIDGenerator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ if (! function_exists('tenant')) {
|
|||
return app(Tenant::class);
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line nullsafe.neverNull
|
||||
return app(Tenant::class)?->getAttribute($key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue