1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 09:34:05 +00:00

Merge branch 'master' into configurable-force-rls

This commit is contained in:
lukinovec 2025-05-15 15:20:21 +02:00 committed by GitHub
commit f9f9e1814a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 1056 additions and 497 deletions

View file

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

View file

@ -7,54 +7,52 @@ 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;
/**
* 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 = true;
/**
* 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';
/**
* Use default parameter names ('tenant' name and tenant key value) instead of the parameter name
* and column name configured in the path resolver config.
*
* You want to enable this when using query string identification while having customized that config.
*/
public static bool $defaultParameterNames = false;
protected array $originalFortifyConfig = [];
@ -76,27 +74,22 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper
protected function useTenantRoutesInFortify(Tenant $tenant): void
{
$tenantKey = $tenant->getTenantKey();
$tenantParameterName = PathTenantResolver::tenantParameterName();
$tenantParameterName = static::$defaultParameterNames ? 'tenant' : PathTenantResolver::tenantParameterName();
$tenantParameterValue = static::$defaultParameterNames ? $tenant->getTenantKey() : 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', route(static::$fortifyHome, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []));
}
$this->config->set('fortify.redirects', $redirects);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,6 @@
declare(strict_types=1);
// todo perhaps create Identification namespace
namespace Stancl\Tenancy\Exceptions;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,39 +13,85 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
/**
* 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 = [];
/**
* Use default parameter names ('tenant' name and tenant key value) instead of the parameter name
* and column name configured in the path resolver config.
*
* You want to enable this when using query string identification while having customized that config.
*/
public static bool $defaultParameterNames = false;
/**
* Override the route() method so that the route name gets prefixed
@ -99,7 +145,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 +170,23 @@ 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::$defaultParameterNames) {
return array_merge($parameters, ['tenant' => tenant()->getTenantKey()]);
} else {
return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]);
}
} else {
return $parameters;
}
}
protected function routeNameOverride(string $name): string|null
{
return static::$overrides[$name] ?? null;
}
}

View file

@ -75,7 +75,10 @@ class TableRLSManager implements RLSPolicyManager
$builder = $this->database->getSchemaBuilder();
// We loop through each table in the database
foreach ($builder->getTableListing() as $table) {
foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) {
// E.g. "public.table_name" -> "table_name"
$table = str($table)->afterLast('.')->toString();
// 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);
@ -105,6 +108,12 @@ class TableRLSManager implements RLSPolicyManager
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
{
// If the foreign key has a comment of 'no-rls', we skip it
// Also skip the foreign key if implicit scoping is off and the foreign key has no comment
if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) {
return;
}
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
throw new RecursiveRelationshipException;
}
@ -112,15 +121,7 @@ class TableRLSManager implements RLSPolicyManager
$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;
}
$paths[] = $currentPath;
} else {
// If not, recursively generate paths for the foreign table
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/**
* Generates a UUID for the tenant key.
*/
class ULIDGenerator implements UniqueIdentifierGenerator
{
public static function generate(Model $model): string|int
{
return Str::ulid()->toString();
}
}

View file

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