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

Merge branch 'master' into abort-deletion-without-database

This commit is contained in:
Samuel Štancl 2026-04-12 19:28:47 +02:00 committed by GitHub
commit 41e6e7c9c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1723 additions and 485 deletions

2
.gitattributes vendored
View file

@ -10,6 +10,7 @@
/.nvim.lua export-ignore /.nvim.lua export-ignore
/art export-ignore /art export-ignore
/coverage export-ignore /coverage export-ignore
/CLAUDE.md export-ignore
/CONTRIBUTING.md export-ignore /CONTRIBUTING.md export-ignore
/INTERNAL.md export-ignore /INTERNAL.md export-ignore
/SUPPORT.md export-ignore /SUPPORT.md export-ignore
@ -19,6 +20,7 @@
/Dockerfile export-ignore /Dockerfile export-ignore
/doctum export-ignore /doctum export-ignore
/phpunit.xml export-ignore /phpunit.xml export-ignore
/static_properties.nu export-ignore
/t export-ignore /t export-ignore
/test export-ignore /test export-ignore
/tests export-ignore /tests export-ignore

View file

@ -17,7 +17,7 @@ jobs:
matrix: matrix:
include: include:
- laravel: "^12.0" - laravel: "^12.0"
php: "8.4" - laravel: "^13.0"
steps: steps:
- name: Checkout - name: Checkout

View file

@ -1,4 +1,3 @@
-- The tailwindcss LSP doesn't play nice with testbench due to the recursive -- The tailwindcss LSP doesn't play nice with testbench due to the recursive
-- `vendor` symlink in `testbench-core/laravel/vendor`, so we nuke its setup method here. -- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here.
-- This prevents the setup() call in neovim config from starting the client (or doing anything at all). vim.lsp.enable('tailwindcss', false)
require('lspconfig').tailwindcss.setup = function () end

View file

@ -49,3 +49,9 @@ Use `composer phpstan` to run our phpstan suite.
Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run). Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run).
If you want to use XDebug, use `composer docker-rebuild-with-xdebug`. If you want to use XDebug, use `composer docker-rebuild-with-xdebug`.
## PHP 8.5
To use PHP 8.5 during development, run:
- `PHP_VERSION=8.5.0 composer docker-rebuild` to build the `test` container with PHP 8.5
- `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core

View file

@ -1,10 +1,8 @@
ARG PHP_VERSION=8.4 ARG PHP_VERSION=8.4
FROM php:${PHP_VERSION}-cli-bookworm FROM php:${PHP_VERSION}-cli-bookworm
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update
git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client
RUN apt-get install -y gnupg2 \ RUN apt-get install -y gnupg2 \
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
@ -12,18 +10,40 @@ RUN apt-get install -y gnupg2 \
&& apt-get update \ && apt-get update \
&& ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18 && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18
RUN apt-get install -y --no-install-recommends \
git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client
RUN apt autoremove && apt clean RUN apt autoremove && apt clean
RUN pecl install apcu && docker-php-ext-enable apcu RUN pecl install apcu && docker-php-ext-enable apcu
RUN pecl install pcov && docker-php-ext-enable pcov RUN pecl install pcov && docker-php-ext-enable pcov
RUN pecl install redis && docker-php-ext-enable redis RUN pecl install redis-6.3.0RC1 && docker-php-ext-enable redis
RUN pecl install memcached && docker-php-ext-enable memcached RUN pecl install memcached && docker-php-ext-enable memcached
RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv
RUN docker-php-ext-install zip && docker-php-ext-enable zip RUN docker-php-ext-install zip && docker-php-ext-enable zip
RUN docker-php-ext-install intl && docker-php-ext-enable intl RUN docker-php-ext-install intl && docker-php-ext-enable intl
RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql
RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql
RUN if [[ "${PHP_VERSION}" == *"8.5"* ]]; then \
mkdir sqlsrv \
&& cd sqlsrv \
&& pecl download pdo_sqlsrv-5.12.0 \
&& tar xzf pdo_sqlsrv-5.12.0.tgz \
&& cd pdo_sqlsrv-5.12.0 \
&& sed -i 's/= dbh->error_mode;/= static_cast<pdo_error_mode>(dbh->error_mode);/' pdo_dbh.cpp \
&& sed -i 's/zval_ptr_dtor( &dbh->query_stmt_zval );/OBJ_RELEASE(dbh->query_stmt_obj);dbh->query_stmt_obj=NULL;/' php_pdo_sqlsrv_int.h \
&& phpize \
&& ./configure --with-php-config=$(which php-config) \
&& make -j$(nproc) \
&& cp modules/pdo_sqlsrv.so $(php -r 'echo ini_get("extension_dir");') \
&& cd / \
&& rm -rf /sqlsrv; \
else \
pecl install pdo_sqlsrv; \
fi
RUN docker-php-ext-enable pdo_sqlsrv
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini"

View file

@ -19,9 +19,9 @@ You won't have to change a thing in your application's code.
- :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes - :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes
- :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains) - :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains)
### [Documentation](https://tenancy-v4.pages.dev/) ### [Documentation](https://v4.tenancyforlaravel.com)
Documentation can be found here: https://tenancy-v4.pages.dev/ Documentation can be found here: https://v4.tenancyforlaravel.com
### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md) ### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md)

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Routing\Route;
use Stancl\Tenancy\Jobs; use Stancl\Tenancy\Jobs;
use Stancl\Tenancy\Events; use Stancl\Tenancy\Events;
use Stancl\Tenancy\ResourceSyncing; use Stancl\Tenancy\ResourceSyncing;
@ -14,13 +13,24 @@ use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
/**
* Tenancy for Laravel.
*
* Documentation: https://tenancyforlaravel.com
*
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
*
* You can also support us, and save time, by purchasing these products:
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
*
* All of these products can also be accessed at https://portal.archte.ch
*/
class TenancyServiceProvider extends ServiceProvider class TenancyServiceProvider extends ServiceProvider
{ {
// By default, no namespace is used to support the callable array syntax. // By default, no namespace is used to support the callable array syntax.
@ -42,7 +52,7 @@ class TenancyServiceProvider extends ServiceProvider
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) { ])->send(function (Events\TenantCreated $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. })->shouldBeQueued(false),
// Listeners\CreateTenantStorage::class, // Listeners\CreateTenantStorage::class,
], ],
@ -65,7 +75,9 @@ class TenancyServiceProvider extends ServiceProvider
Jobs\DeleteDatabase::class, Jobs\DeleteDatabase::class,
])->send(function (Events\TenantDeleted $event) { ])->send(function (Events\TenantDeleted $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. })->shouldBeQueued(false),
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
], ],
Events\TenantMaintenanceModeEnabled::class => [], Events\TenantMaintenanceModeEnabled::class => [],
@ -114,6 +126,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
], ],
ResourceSyncing\Events\SyncedResourceDeleted::class => [
ResourceSyncing\Listeners\DeleteResourceMapping::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
], ],
@ -126,7 +141,9 @@ class TenancyServiceProvider extends ServiceProvider
ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [
ResourceSyncing\Listeners\DeleteResourceInTenant::class, ResourceSyncing\Listeners\DeleteResourceInTenant::class,
], ],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
// Fired only when a synced resource is changed (as a result of syncing)
// in a different DB than DB from which the change originates (to avoid infinite loops)
ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [],
// Storage symlinks // Storage symlinks
@ -150,6 +167,10 @@ class TenancyServiceProvider extends ServiceProvider
// ? $tenant->domain // ? $tenant->domain
// : $tenant->domains->first()->domain; // : $tenant->domains->first()->domain;
// //
// if (is_null($tenantDomain)) {
// return $originalRootUrl;
// }
//
// $scheme = str($originalRootUrl)->before('://'); // $scheme = str($originalRootUrl)->before('://');
// //
// if (str_contains($tenantDomain, '.')) { // if (str_contains($tenantDomain, '.')) {
@ -185,7 +206,7 @@ class TenancyServiceProvider extends ServiceProvider
// // To make Livewire v3 work with Tenancy, make the update route universal. // // To make Livewire v3 work with Tenancy, make the update route universal.
// Livewire::setUpdateRoute(function ($handle) { // Livewire::setUpdateRoute(function ($handle) {
// return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); // return Route::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]);
// }); // });
} }
@ -206,7 +227,7 @@ class TenancyServiceProvider extends ServiceProvider
{ {
$this->app->booted(function () { $this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) { if (file_exists(base_path('routes/tenant.php'))) {
RouteFacade::namespace(static::$controllerNamespace) Route::namespace(static::$controllerNamespace)
->middleware('tenant') ->middleware('tenant')
->group(base_path('routes/tenant.php')); ->group(base_path('routes/tenant.php'));
} }
@ -227,24 +248,7 @@ class TenancyServiceProvider extends ServiceProvider
/** @var CloneRoutesAsTenant $cloneRoutes */ /** @var CloneRoutesAsTenant $cloneRoutes */
$cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class);
// The cloning action has two modes: /** See CloneRoutesAsTenant for usage details. */
// 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property.
// You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action.
//
// By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle()
// will clone all routes that have either 'clone' or 'universal' middleware (mentioning 'universal' since that's a common use case).
//
// Also, you can use the shouldClone() method to provide a custom closure that determines if a route should be cloned.
//
// 2. Clone only the routes that were manually added to the action using cloneRoute().
//
// Regardless of the mode, you can provide a custom closure for defining the cloned route, e.g.:
// $cloneRoutesAction->cloneUsing(function (Route $route) {
// RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName());
// })->handle();
// This will make all cloned routes use the custom closure to define the cloned route instead of the default behavior.
// See Stancl\Tenancy\Actions\CloneRoutesAsTenant for more details.
$cloneRoutes->handle(); $cloneRoutes->handle();
} }

View file

@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\UniqueIdentifierGenerators; use Stancl\Tenancy\UniqueIdentifierGenerators;
/**
* Tenancy for Laravel.
*
* Documentation: https://tenancyforlaravel.com
*
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
*
* You can also support us, and save time, by purchasing these products:
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
*
* All of these products can also be accessed at https://portal.archte.ch
*/
return [ return [
/** /**
* Configuration for the models used by Tenancy. * Configuration for the models used by Tenancy.
@ -33,6 +48,8 @@ return [
* SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs). * SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs).
* *
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator
* @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator
@ -47,7 +64,7 @@ return [
* Only relevant if you're using the domain or subdomain identification middleware. * Only relevant if you're using the domain or subdomain identification middleware.
*/ */
'central_domains' => [ 'central_domains' => [
str(env('APP_URL'))->after('://')->before('/')->toString(), str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(),
], ],
/** /**
@ -155,6 +172,7 @@ return [
Bootstrappers\DatabaseTenancyBootstrapper::class, Bootstrappers\DatabaseTenancyBootstrapper::class,
Bootstrappers\CacheTenancyBootstrapper::class, Bootstrappers\CacheTenancyBootstrapper::class,
// Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper // Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper
// Bootstrappers\DatabaseCacheBootstrapper::class, // Separates cache by DB rather than by prefix, must run after DatabaseTenancyBootstrapper
Bootstrappers\FilesystemTenancyBootstrapper::class, Bootstrappers\FilesystemTenancyBootstrapper::class,
Bootstrappers\QueueTenancyBootstrapper::class, Bootstrappers\QueueTenancyBootstrapper::class,
// Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed // Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
@ -163,9 +181,10 @@ return [
Bootstrappers\DatabaseSessionBootstrapper::class, Bootstrappers\DatabaseSessionBootstrapper::class,
// Configurable bootstrappers // Configurable bootstrappers
// Bootstrappers\TenantConfigBootstrapper::class,
// Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class,
// Bootstrappers\UrlGeneratorBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class,
// Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Bootstrappers\MailConfigBootstrapper::class,
// Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class,
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class,
@ -294,7 +313,7 @@ return [
* *
* Note: This will implicitly add your configured session store to the list of prefixed stores above. * Note: This will implicitly add your configured session store to the list of prefixed stores above.
*/ */
'scope_sessions' => true, 'scope_sessions' => in_array(env('SESSION_DRIVER'), ['redis', 'memcached', 'dynamodb', 'apc'], true),
'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
], ],
@ -404,7 +423,6 @@ return [
'features' => [ 'features' => [
// Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class, // Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\TenantConfig::class,
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class,
// Stancl\Tenancy\Features\ViteBundler::class, // Stancl\Tenancy\Features\ViteBundler::class,
// Stancl\Tenancy\Features\DisallowSqliteAttach::class, // Stancl\Tenancy\Features\DisallowSqliteAttach::class,
@ -428,7 +446,6 @@ return [
/** /**
* Pending tenants config. * Pending tenants config.
* This is useful if you're looking for a way to always have a tenant ready to be used.
*/ */
'pending' => [ 'pending' => [
/** /**
@ -437,6 +454,7 @@ return [
* Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.) * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.)
*/ */
'include_in_queries' => true, 'include_in_queries' => true,
/** /**
* Defines how many pending tenants you want to have ready in the pending tenant pool. * Defines how many pending tenants you want to have ready in the pending tenant pool.
* This depends on the volume of tenants you're creating. * This depends on the volume of tenants you're creating.

View file

@ -18,22 +18,23 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^12.0", "illuminate/support": "^12.0|^13.0",
"laravel/tinker": "^2.0", "laravel/tinker": "^2.0|^3.0",
"ramsey/uuid": "^4.7.3", "ramsey/uuid": "^4.7.3",
"stancl/jobpipeline": "2.0.0-rc6", "stancl/jobpipeline": "2.0.0-rc7",
"stancl/virtualcolumn": "^1.5.0", "stancl/virtualcolumn": "^1.5.0",
"spatie/invade": "*", "spatie/invade": "*",
"laravel/prompts": "0.*" "laravel/prompts": "0.*"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^12.0", "laravel/framework": "^13.0",
"orchestra/testbench": "^10.0", "orchestra/testbench": "^10.0|^11.0",
"league/flysystem-aws-s3-v3": "^3.12.2", "league/flysystem-aws-s3-v3": "^3.12.2",
"doctrine/dbal": "^3.6.0", "doctrine/dbal": "^3.6.0",
"spatie/valuestore": "^1.2.5", "spatie/valuestore": "^1.2.5",
"pestphp/pest": "^3.0", "pestphp/pest": "^4.0",
"larastan/larastan": "^3.0" "larastan/larastan": "^3.0",
"league/flysystem-path-prefixing": "^3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -65,13 +66,16 @@
"docker-restart": "docker compose down && docker compose up -d", "docker-restart": "docker compose down && docker compose up -d",
"docker-rebuild": [ "docker-rebuild": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 docker compose up -d --no-deps --build" "docker compose up -d --no-deps --build"
], ],
"docker-rebuild-with-xdebug": [ "docker-rebuild-with-xdebug": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"PHP_VERSION=8.4 XDEBUG_ENABLED=true docker compose up -d --no-deps --build" "XDEBUG_ENABLED=true docker compose up -d --no-deps --build"
], ],
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"php85-patch": [
"php -r '$file=\"vendor/orchestra/testbench-core/laravel/config/database.php\"; file_put_contents($file, str_replace(\"PDO::MYSQL_ATTR_SSL_CA\", \"Pdo\\\\Mysql::ATTR_SSL_CA\", file_get_contents($file)));'"
],
"testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache",

View file

@ -3,6 +3,7 @@ services:
build: build:
context: . context: .
args: args:
PHP_VERSION: ${PHP_VERSION:-8.4}
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false}
depends_on: depends_on:
mysql: mysql:
@ -80,7 +81,7 @@ services:
image: mcr.microsoft.com/mssql/server:2022-latest image: mcr.microsoft.com/mssql/server:2022-latest
environment: environment:
- ACCEPT_EULA=Y - ACCEPT_EULA=Y
- SA_PASSWORD=P@ssword # todo reuse env from above - SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD
healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432
test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
interval: 10s interval: 10s

View file

@ -46,10 +46,6 @@ parameters:
message: '#PHPDoc tag \@param has invalid value \(dynamic#' message: '#PHPDoc tag \@param has invalid value \(dynamic#'
paths: paths:
- src/helpers.php - src/helpers.php
-
message: '#Illuminate\\Routing\\UrlGenerator#'
paths:
- src/Bootstrappers/FilesystemTenancyBootstrapper.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#'
- '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#'

View file

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

View file

@ -15,7 +15,7 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
class BroadcastingConfigBootstrapper implements TenancyBootstrapper class BroadcastingConfigBootstrapper implements TenancyBootstrapper
{ {
/** /**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature). * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper).
* *
* For example: * For example:
* [ * [

View file

@ -99,11 +99,14 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
{ {
$names = $this->config->get('tenancy.cache.stores'); $names = $this->config->get('tenancy.cache.stores');
if ( if ($this->config->get('tenancy.cache.scope_sessions', true)) {
$this->config->get('tenancy.cache.scope_sessions', true) && // These are the only cache driven session backends (see Laravel's config/session.php)
in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) {
) { throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions');
$names[] = $this->getSessionCacheStoreName(); } else {
// Scoping sessions using this bootstrapper implicitly adds the session store to $names
$names[] = $this->getSessionCacheStoreName();
}
} }
$names = array_unique($names); $names = array_unique($names);
@ -112,6 +115,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper
$store = $this->config->get("cache.stores.{$name}"); $store = $this->config->get("cache.stores.{$name}");
if ($store === null || $store['driver'] === 'file') { if ($store === null || $store['driver'] === 'file') {
// 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper
return false; return false;
} }

View file

@ -63,13 +63,17 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
$stores = $this->scopedStoreNames(); $stores = $this->scopedStoreNames();
foreach ($stores as $storeName) { foreach ($stores as $storeName) {
$this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection") ?? config('tenancy.database.central_connection');
$this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection"); $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection") ?? config('tenancy.database.central_connection');
$this->config->set("cache.stores.{$storeName}.connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.connection", 'tenant');
$this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant');
$this->cache->purge($storeName); /** @var DatabaseStore $store */
$store = $this->cache->store($storeName)->getStore();
$store->setConnection(DB::connection('tenant'));
$store->setLockConnection(DB::connection('tenant'));
} }
if (static::$adjustGlobalCacheManager) { if (static::$adjustGlobalCacheManager) {
@ -78,8 +82,8 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
// *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that // *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that
// (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution. // (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution.
$originalConnections = array_combine($stores, array_map(fn (string $storeName) => [ $originalConnections = array_combine($stores, array_map(fn (string $storeName) => [
'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), 'connection' => $this->originalConnections[$storeName],
'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), 'lockConnection' => $this->originalLockConnections[$storeName],
], $stores)); ], $stores));
TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) { TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) {
@ -100,7 +104,11 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper
$this->config->set("cache.stores.{$storeName}.connection", $originalConnection); $this->config->set("cache.stores.{$storeName}.connection", $originalConnection);
$this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]); $this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]);
$this->cache->purge($storeName); /** @var DatabaseStore $store */
$store = $this->cache->store($storeName)->getStore();
$store->setConnection(DB::connection($this->originalConnections[$storeName]));
$store->setLockConnection(DB::connection($this->originalLockConnections[$storeName]));
} }
TenancyServiceProvider::$adjustCacheManagerUsing = null; TenancyServiceProvider::$adjustCacheManagerUsing = null;

View file

@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.'); throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');
} }
// Better debugging, but breaks cached lookup in prod // Better debugging, but breaks cached lookup, so we disable this in prod
if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149 if (app()->environment('local') || app()->environment('testing')) {
$database = $tenant->database()->getName(); $database = $tenant->database()->getName();
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection? if (! $tenant->database()->manager()->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database); throw new TenantDatabaseDoesNotExistException($database);
} }
} }

View file

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

View file

@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant;
class MailConfigBootstrapper implements TenancyBootstrapper class MailConfigBootstrapper implements TenancyBootstrapper
{ {
/** /**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature). * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper).
* *
* For example: * For example:
* [ * [

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class TenantConfigBootstrapper implements TenancyBootstrapper
{
public array $originalConfig = [];
/** @var array<string, string|array> */
public static array $storageToConfigMap = [
// 'paypal_api_key' => 'services.paypal.api_key',
];
public function __construct(
protected Repository $config,
) {}
public function bootstrap(Tenant $tenant): void
{
foreach (static::$storageToConfigMap as $storageKey => $configKey) {
/** @var Tenant&Model $tenant */
$override = Arr::get($tenant, $storageKey);
if (! is_null($override)) {
if (is_array($configKey)) {
foreach ($configKey as $key) {
$this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key);
$this->config->set($key, $override);
}
} else {
$this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
}
public function revert(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Routing\UrlGenerator; use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
@ -78,6 +79,10 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper
} }
} }
// Inherit scheme (http/https) from the original generator
$originalScheme = Str::before($this->originalUrlGenerator->formatScheme(), '://');
$newGenerator->forceScheme($originalScheme);
$newGenerator->defaults($defaultParameters); $newGenerator->defaults($defaultParameters);
$newGenerator->setSessionResolver(function () { $newGenerator->setSessionResolver(function () {

View file

@ -8,7 +8,7 @@ use Illuminate\Console\Command;
class CreatePendingTenants extends Command class CreatePendingTenants extends Command
{ {
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}'; protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}';
protected $description = 'Create pending tenants.'; protected $description = 'Create pending tenants.';

View file

@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command
#[\SensitiveParameter] #[\SensitiveParameter]
string $password, string $password,
): DatabaseConfig { ): DatabaseConfig {
// This is a bit of a hack. We want to use our existing createUser() logic.
// That logic needs a DatabaseConfig instance. However, we aren't really working
// with any specific tenant here. We also *don't* want to use anything tenant-specific
// here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig
// instance for this purpose. The easiest way to do that is to grab an empty Tenant model
// (we use TenantWithDatabase in RLS) and manually create the host connection, just like
// DatabaseConfig::manager() would. We don't call that method since we want to use our existing
// PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager".
/** @var TenantWithDatabase $tenantModel */ /** @var TenantWithDatabase $tenantModel */
$tenantModel = tenancy()->model(); $tenantModel = tenancy()->model();
// Use a temporary DatabaseConfig instance to set the host connection
$temporaryDbConfig = $tenantModel->database(); $temporaryDbConfig = $tenantModel->database();
$temporaryDbConfig->purgeHostConnection(); $temporaryDbConfig->purgeHostConnection();
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands;
use Closure; use Closure;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
class Install extends Command class Install extends Command
{ {
@ -128,14 +129,27 @@ class Install extends Command
public function askForSupport(): void public function askForSupport(): void
{ {
if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) {
if (PHP_OS_FAMILY === 'Darwin') { $ghVersion = Process::run('gh --version');
exec('open https://github.com/archtechx/tenancy'); $starred = false;
// Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool
if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) {
$starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy');
$starred = $starRequest->successful();
} }
if (PHP_OS_FAMILY === 'Windows') {
exec('start https://github.com/archtechx/tenancy'); if ($starred) {
} $this->components->success('Repository starred via gh CLI, thank you!');
if (PHP_OS_FAMILY === 'Linux') { } else {
exec('xdg-open https://github.com/archtechx/tenancy'); if (PHP_OS_FAMILY === 'Darwin') {
exec('open https://github.com/archtechx/tenancy');
}
if (PHP_OS_FAMILY === 'Windows') {
exec('start https://github.com/archtechx/tenancy');
}
if (PHP_OS_FAMILY === 'Linux') {
exec('xdg-open https://github.com/archtechx/tenancy');
}
} }
} }
} }

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Stancl\Tenancy\Features\UserImpersonation;
/**
* Clears expired impersonation tokens.
*
* Tokens older than UserImpersonation::$ttl are considered expired.
*
* @see Stancl\Tenancy\Features\UserImpersonation
*/
class PurgeImpersonationTokens extends Command
{
protected $signature = 'tenants:purge-impersonation-tokens';
protected $description = 'Clear expired impersonation tokens.';
public function handle(): int
{
$this->components->info('Deleting expired impersonation tokens.');
$expirationDate = now()->subSeconds(UserImpersonation::$ttl);
$impersonationTokenModel = UserImpersonation::modelClass();
$deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate)
->delete();
$this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.');
return 0;
}
}

View file

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

View file

@ -14,10 +14,9 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
// todo@refactor move this logic to some dedicated static class?
/** /**
* @mixin \Stancl\Tenancy\Tenancy * @mixin \Stancl\Tenancy\Tenancy
* @internal The public methods in this trait should not be understood to be a public stable API.
*/ */
trait DealsWithRouteContexts trait DealsWithRouteContexts
{ {

View file

@ -17,8 +17,8 @@ trait HasTenantOptions
protected function getOptions() protected function getOptions()
{ {
return array_merge([ return array_merge([
['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null], new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null),
['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'], new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs
], parent::getOptions()); ], parent::getOptions());
} }

View file

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

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts; namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Tenancy;
/** Additional features, like Telescope tags and tenant redirects. */ /** Additional features, like Telescope tags and tenant redirects. */
interface Feature interface Feature
{ {
public function bootstrap(Tenancy $tenancy): void; public function bootstrap(): void;
} }

View file

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

View file

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

View file

@ -28,7 +28,8 @@ trait HasDatabase
} }
if ($key === $this->internalPrefix() . 'db_connection') { if ($key === $this->internalPrefix() . 'db_connection') {
// Remove DB connection because that's not used here // Remove DB connection because that's not used for the connection *contents*.
// Instead the code uses getInternal('db_connection').
continue; continue;
} }

View file

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

View file

@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingPendingTenant; use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated; use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Events\PullingPendingTenant;
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
/** /**
* @property ?Carbon $pending_since * @property ?Carbon $pending_since
* *
@ -50,46 +49,75 @@ trait HasPending
*/ */
public static function createPending(array $attributes = []): Model&Tenant public static function createPending(array $attributes = []): Model&Tenant
{ {
$tenant = static::create($attributes); $tenant = null;
event(new CreatingPendingTenant($tenant)); try {
$tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes));
// Update the pending_since value only after the tenant is created so it's event(new CreatingPendingTenant($tenant));
// Not marked as pending until finishing running the migrations, seeders, etc. } finally {
$tenant->update([ // Update the pending_since value only after the tenant is created so it's
'pending_since' => now()->timestamp, // not marked as pending until after migrations, seeders, etc are run.
]); $tenant?->update([
'pending_since' => now()->timestamp,
]);
}
event(new PendingTenantCreated($tenant)); event(new PendingTenantCreated($tenant));
return $tenant; return $tenant;
} }
/** Pull a pending tenant. */ /**
public static function pullPending(): Model&Tenant * Attributes to be set when a pending tenant is initially created.
*
* @param array<string, mixed> $attributes The attributes passed to createPending() (will be merged with the returned array)
* @return array<string, mixed>
*/
public static function getPendingAttributes(array $attributes): array
{
return [];
}
/**
* Pull a pending tenant from the pool or create a new one if the pool is empty.
*
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPending(array $attributes = []): Model&Tenant
{ {
/** @var Model&Tenant $pendingTenant */ /** @var Model&Tenant $pendingTenant */
$pendingTenant = static::pullPendingFromPool(true); $pendingTenant = static::pullPendingFromPool(true, $attributes);
return $pendingTenant; return $pendingTenant;
} }
/** Try to pull a tenant from the pool of pending tenants. */ /**
public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant * Try to pull a tenant from the pool of pending tenants.
*
* @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned.
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant
{ {
/** @var (Model&Tenant)|null $tenant */ $tenant = DB::transaction(function () use ($attributes): ?Tenant {
$tenant = static::onlyPending()->first(); /** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first();
if ($tenant !== null) {
event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
}
return $tenant;
});
if ($tenant === null) { if ($tenant === null) {
return $firstOrCreate ? static::create($attributes) : null; return $firstOrCreate ? static::create($attributes) : null;
} }
event(new PullingPendingTenant($tenant)); // Only triggered if a tenant that was pulled from the pool is returned
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
event(new PendingTenantPulled($tenant)); event(new PendingTenantPulled($tenant));
return $tenant; return $tenant;

View file

@ -10,13 +10,6 @@ use Illuminate\Database\Eloquent\Scope;
class PendingScope implements Scope class PendingScope implements Scope
{ {
/**
* All of the extensions to be added to the builder.
*
* @var string[]
*/
protected $extensions = ['WithPending', 'WithoutPending', 'OnlyPending'];
/** /**
* Apply the scope to a given Eloquent query builder. * Apply the scope to a given Eloquent query builder.
* *
@ -32,26 +25,21 @@ class PendingScope implements Scope
} }
/** /**
* Extend the query builder with the needed functions. * Add methods to the query builder.
* *
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
public function extend(Builder $builder) public function extend(Builder $builder): void
{ {
foreach ($this->extensions as $extension) { $this->addWithPending($builder);
$this->{"add{$extension}"}($builder); $this->addWithoutPending($builder);
} $this->addOnlyPending($builder);
} }
/** /**
* Add the with-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addWithPending(Builder $builder) protected function addWithPending(Builder $builder): void
{ {
$builder->macro('withPending', function (Builder $builder, $withPending = true) { $builder->macro('withPending', function (Builder $builder, $withPending = true) {
if (! $withPending) { if (! $withPending) {
@ -63,13 +51,9 @@ class PendingScope implements Scope
} }
/** /**
* Add the without-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addWithoutPending(Builder $builder) protected function addWithoutPending(Builder $builder): void
{ {
$builder->macro('withoutPending', function (Builder $builder) { $builder->macro('withoutPending', function (Builder $builder) {
$builder->withoutGlobalScope(static::class) $builder->withoutGlobalScope(static::class)
@ -81,13 +65,9 @@ class PendingScope implements Scope
} }
/** /**
* Add the only-pending extension to the builder.
*
* @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder
*
* @return void
*/ */
protected function addOnlyPending(Builder $builder) protected function addOnlyPending(Builder $builder): void
{ {
$builder->macro('onlyPending', function (Builder $builder) { $builder->macro('onlyPending', function (Builder $builder) {
$builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since')); $builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since'));

View file

@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant;
use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException;
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
// todo@dbRefactor refactor host connection logic to make customizing the host connection easier
class DatabaseConfig class DatabaseConfig
{ {
/** The tenant whose database we're dealing with. */ /** The tenant whose database we're dealing with. */
@ -115,7 +114,7 @@ class DatabaseConfig
{ {
$this->tenant->setInternal('db_name', $this->getName()); $this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this));
} }
@ -137,7 +136,9 @@ class DatabaseConfig
} }
if ($template = config('tenancy.database.template_tenant_connection')) { if ($template = config('tenancy.database.template_tenant_connection')) {
return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); return is_array($template)
? array_merge($this->getCentralConnection(), $template)
: config("database.connections.{$template}");
} }
return $this->getCentralConnection(); return $this->getCentralConnection();
@ -176,10 +177,10 @@ class DatabaseConfig
$config = $this->tenantConfig; $config = $this->tenantConfig;
$templateConnection = $this->getTemplateConnection(); $templateConnection = $this->getTemplateConnection();
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet // We remove the username and password because the user with these credentials is not yet created.
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, // If you need to provide a username and a password when using a permission controlled database manager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key // consider creating a new connection and use it as `tenancy_db_connection`.
unset($config['username'], $config['password']); unset($config['username'], $config['password']);
} }
@ -191,7 +192,7 @@ class DatabaseConfig
} }
/** /**
* Purge the previous tenant connection before opening it for another tenant. * Purge the previous host connection before opening it for another tenant.
*/ */
public function purgeHostConnection(): void public function purgeHostConnection(): void
{ {
@ -199,20 +200,20 @@ class DatabaseConfig
} }
/** /**
* Get the TenantDatabaseManager for this tenant's connection. * Get the TenantDatabaseManager for this tenant's host connection.
* *
* @throws NoConnectionSetException|DatabaseManagerNotRegisteredException * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException
*/ */
public function manager(): Contracts\TenantDatabaseManager public function manager(): Contracts\TenantDatabaseManager
{ {
// Laravel caches the previous PDO connection, so we purge it to be able to change the connection details // Laravel persists the PDO connection, so we purge it to be able to change the connection details
$this->purgeHostConnection(); $this->purgeHostConnection();
// Create the tenant host connection config // Create the tenant host connection config
$tenantHostConnectionName = $this->getTenantHostConnectionName(); $tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); $manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver"));
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName); $manager->setConnection($tenantHostConnectionName);
@ -222,12 +223,11 @@ class DatabaseConfig
} }
/** /**
* todo@name come up with a better name * Get the TenantDatabaseManager for a given database driver.
* Get database manager class from the given connection config's driver.
* *
* @throws DatabaseManagerNotRegisteredException * @throws DatabaseManagerNotRegisteredException
*/ */
protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager
{ {
$databaseManagers = config('tenancy.database.managers'); $databaseManagers = config('tenancy.database.managers');

View file

@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
{ {
$database = $databaseConfig->getName(); $database = $databaseConfig->getName();
$username = $databaseConfig->getUsername(); $username = $databaseConfig->getUsername();
$hostname = $databaseConfig->connection()['host'];
$password = $databaseConfig->getPassword(); $password = $databaseConfig->getPassword();
$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");

View file

@ -30,10 +30,6 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
$tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'");
// Grant permissions to any existing tables. This is used with RLS // 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) { foreach ($tables as $table) {
$tableName = $table->table_name; $tableName = $table->table_name;

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers; namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use AssertionError;
use Closure; use Closure;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use PDO; use PDO;
@ -15,17 +14,12 @@ use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager class SQLiteDatabaseManager implements TenantDatabaseManager
{ {
/** /**
* SQLite Database path without ending slash. * SQLite database directory path.
*
* Defaults to database_path().
*/ */
public static string|null $path = null; public static string|null $path = null;
/**
* Should the WAL journal mode be used for newly created databases.
*
* @see https://www.sqlite.org/pragma.html#pragma_journal_mode
*/
public static bool $WAL = true;
/* /*
* If this isn't null, a connection to the tenant DB will be created * If this isn't null, a connection to the tenant DB will be created
* and passed to the provided closure, for the purpose of keeping the * and passed to the provided closure, for the purpose of keeping the
@ -84,30 +78,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
// or creating a closure holding a reference to it and passing that to register_shutdown_function(). // or creating a closure holding a reference to it and passing that to register_shutdown_function().
$name = '_tenancy_inmemory_' . $tenant->getTenantKey(); $name = '_tenancy_inmemory_' . $tenant->getTenantKey();
$tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]); $tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared");
$tenant->save();
return true; return true;
} }
try { return file_put_contents($this->getPath($name), '') !== false;
if (file_put_contents($path = $this->getPath($name), '') === false) {
return false;
}
if (static::$WAL) {
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// @phpstan-ignore-next-line method.nonObject
assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.');
}
return true;
} catch (AssertionError $e) {
throw $e;
} catch (Throwable) {
return false;
}
} }
public function deleteDatabase(TenantWithDatabase $tenant): bool public function deleteDatabase(TenantWithDatabase $tenant): bool
@ -122,8 +99,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return true; return true;
} }
$path = $this->getPath($name);
try { try {
return unlink($this->getPath($name)); unlink($path . '-journal');
unlink($path . '-wal');
unlink($path . '-shm');
} catch (Throwable) {}
try {
return unlink($path);
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@ -150,15 +135,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return $baseConfig; return $baseConfig;
} }
public function setConnection(string $connection): void
{
//
}
public function getPath(string $name): string public function getPath(string $name): string
{ {
if (static::$path) { if (static::$path) {
return static::$path . DIRECTORY_SEPARATOR . $name; return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
} }
return database_path($name); return database_path($name);

View file

@ -4,4 +4,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Events; namespace Stancl\Tenancy\Events;
/**
* Importantly, listeners for this event should not switch tenancy context.
*
* This event is fired from within a database transaction.
*/
class PullingPendingTenant extends Contracts\TenantEvent {} class PullingPendingTenant extends Contracts\TenantEvent {}

View file

@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class CrossDomainRedirect implements Feature class CrossDomainRedirect implements Feature
{ {
public function bootstrap(Tenancy $tenancy): void public function bootstrap(): void
{ {
RedirectResponse::macro('domain', function (string $domain) { RedirectResponse::macro('domain', function (string $domain) {
/** @var RedirectResponse $this */ /** @var RedirectResponse $this */

View file

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features; namespace Stancl\Tenancy\Features;
use Exception;
use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\SQLiteConnection; use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use PDO; use PDO;
use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class DisallowSqliteAttach implements Feature class DisallowSqliteAttach implements Feature
{ {
protected static bool|null $loadExtensionSupported = null;
public static string|false|null $extensionPath = null; public static string|false|null $extensionPath = null;
public function bootstrap(Tenancy $tenancy): void public function bootstrap(): void
{ {
// Handle any already resolved connections // Handle any already resolved connections
foreach (DB::getConnections() as $connection) { foreach (DB::getConnections() as $connection) {
if ($connection instanceof SQLiteConnection) { if ($connection instanceof SQLiteConnection) {
if (! $this->loadExtension($connection->getPdo())) { if (! $this->setAuthorizer($connection->getPdo())) {
return; return;
} }
} }
@ -31,42 +28,54 @@ class DisallowSqliteAttach implements Feature
// Apply the change to all sqlite connections resolved in the future // Apply the change to all sqlite connections resolved in the future
DB::extend('sqlite', function ($config, $name) { DB::extend('sqlite', function ($config, $name) {
$conn = app(ConnectionFactory::class)->make($config, $name); $conn = app(ConnectionFactory::class)->make($config, $name);
$this->loadExtension($conn->getPdo()); $this->setAuthorizer($conn->getPdo());
return $conn; return $conn;
}); });
} }
protected function loadExtension(PDO $pdo): bool protected function setAuthorizer(PDO $pdo): bool
{ {
if (static::$loadExtensionSupported === null) { if (PHP_VERSION_ID >= 80500) {
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); $this->setNativeAuthorizer($pdo);
return true;
} }
if (static::$loadExtensionSupported === false) { static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
return false;
} if ((! $loadExtensionSupported) ||
if (static::$extensionPath === false) { (static::$extensionPath === false) ||
return false; (PHP_INT_SIZE !== 8)
} ) return false;
$suffix = match (PHP_OS_FAMILY) { $suffix = match (PHP_OS_FAMILY) {
'Linux' => 'so', 'Linux' => 'so',
'Windows' => 'dll', 'Windows' => 'dll',
'Darwin' => 'dylib', 'Darwin' => 'dylib',
default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), default => 'error',
}; };
if ($suffix === 'error') return false;
$arch = php_uname('m'); $arch = php_uname('m');
$arm = $arch === 'aarch64' || $arch === 'arm64'; $arm = $arch === 'aarch64' || $arch === 'arm64';
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix)); static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
if (static::$extensionPath === false) { if (static::$extensionPath === false) return false;
return false;
}
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound $pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound
return true; return true;
} }
protected function setNativeAuthorizer(PDO $pdo): void
{
// @phpstan-ignore method.notFound
$pdo->setAuthorizer(static function (int $action): int {
return $action === 24 // SQLITE_ATTACH
? PDO\Sqlite::DENY
: PDO\Sqlite::OK;
});
}
} }

View file

@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features;
use Laravel\Telescope\IncomingEntry; use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope; use Laravel\Telescope\Telescope;
use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class TelescopeTags implements Feature class TelescopeTags implements Feature
{ {
public function bootstrap(Tenancy $tenancy): void public function bootstrap(): void
{ {
if (! class_exists(Telescope::class)) { if (! class_exists(Telescope::class)) {
return; return;

View file

@ -12,8 +12,10 @@ use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\RevertedToCentralContext;
use Stancl\Tenancy\Events\TenancyBootstrapped; use Stancl\Tenancy\Events\TenancyBootstrapped;
use Stancl\Tenancy\Tenancy;
// todo@release remove this class
/** @deprecated Use the TenantConfigBootstrapper instead. */
class TenantConfig implements Feature class TenantConfig implements Feature
{ {
public array $originalConfig = []; public array $originalConfig = [];
@ -27,7 +29,7 @@ class TenantConfig implements Feature
protected Repository $config, protected Repository $config,
) {} ) {}
public function bootstrap(Tenancy $tenancy): void public function bootstrap(): void
{ {
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
/** @var Tenant $tenant */ /** @var Tenant $tenant */

View file

@ -17,9 +17,9 @@ class UserImpersonation implements Feature
/** The lifespan of impersonation tokens (in seconds). */ /** The lifespan of impersonation tokens (in seconds). */
public static int $ttl = 60; public static int $ttl = 60;
public function bootstrap(Tenancy $tenancy): void public function bootstrap(): void
{ {
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
return UserImpersonation::modelClass()::create([ return UserImpersonation::modelClass()::create([
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId, 'user_id' => $userId,
@ -44,12 +44,20 @@ class UserImpersonation implements Feature
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
abort_if($tokenExpired, 403); if ($tokenExpired) {
$token->delete();
abort(403);
}
$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn()); $tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
$currentTenantId = (string) tenant()->getTenantKey(); $currentTenantId = (string) tenant()->getTenantKey();
abort_unless($tokenTenantId === $currentTenantId, 403); if ($tokenTenantId !== $currentTenantId) {
$token->delete();
abort(403);
}
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember); Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);

View file

@ -7,19 +7,14 @@ namespace Stancl\Tenancy\Features;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Vite; use Illuminate\Support\Facades\Vite;
use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class ViteBundler implements Feature class ViteBundler implements Feature
{ {
/** @var Application */ public function __construct(
protected $app; protected Application $app,
) {}
public function __construct(Application $app) public function bootstrap(): void
{
$this->app = $app;
}
public function bootstrap(Tenancy $tenancy): void
{ {
Vite::createAssetPathsUsing(function ($path, $secure = null) { Vite::createAssetPathsUsing(function ($path, $secure = null) {
return global_asset($path); return global_asset($path);

View file

@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue
try { try {
$databaseManager->ensureTenantCanBeCreated($this->tenant); $databaseManager->ensureTenantCanBeCreated($this->tenant);
$this->tenant->database()->manager()->createDatabase($this->tenant); $databaseCreated = $this->tenant->database()->manager()->createDatabase($this->tenant);
assert($databaseCreated);
event(new DatabaseCreated($this->tenant)); event(new DatabaseCreated($this->tenant));
} catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) { } catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) {

View file

@ -4,18 +4,25 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\Contracts\TenantEvent;
/**
* Can be used to manually create framework directories in the tenant storage when storage_path() is scoped.
*
* Useful when using real-time facades which use the framework/cache directory.
*
* Generally not needed anymore as the directory is also created by the FilesystemTenancyBootstrapper.
*/
class CreateTenantStorage class CreateTenantStorage
{ {
public function handle(TenantCreated $event): void public function handle(TenantEvent $event): void
{ {
$storage_path = tenancy()->run($event->tenant, fn () => storage_path()); $storage_path = tenancy()->run($event->tenant, fn () => storage_path());
$cache_path = "$storage_path/framework/cache"; $cache_path = "$storage_path/framework/cache";
if (! is_dir($cache_path)) { if (! is_dir($cache_path)) {
// Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades) // Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades)
mkdir($cache_path, 0777, true); mkdir($cache_path, 0750, true);
} }
} }
} }

View file

@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Listeners; namespace Stancl\Tenancy\Listeners;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Events\DeletingTenant; use Stancl\Tenancy\Events\Contracts\TenantEvent;
class DeleteTenantStorage class DeleteTenantStorage
{ {
public function handle(DeletingTenant $event): void public function handle(TenantEvent $event): void
{ {
$path = tenancy()->run($event->tenant, fn () => storage_path()); $path = tenancy()->run($event->tenant, fn () => storage_path());

View file

@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Resolvers\PathTenantResolver;
// todo@earlyIdReview
/** /**
* Conditionally removes the tenant parameter from matched routes when using kernel path identification. * Conditionally removes the tenant parameter from matched routes when using kernel path identification.
* *

View file

@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware;
use Closure; use Closure;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
@ -14,7 +13,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
if (! tenant()) { if (! tenant()) {
throw new TenancyNotInitializedException; // If there's no tenant, there's no tenant to check for maintenance mode.
// Since tenant identification middleware has higher priority than this
// middleware, a missing tenant would have already lead to request termination.
// (And even if priority were misconfigured, the request would simply get
// terminated *after* this middleware.)
// Therefore, we are likely on a universal route, in central context.
return $next($request);
} }
if (tenant('maintenance_mode')) { if (tenant('maintenance_mode')) {

View file

@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Enums\RouteMode;
/** /**
* todo@name come up with a better name.
*
* Prevents accessing central domains in the tenant context/tenant domains in the central context. * Prevents accessing central domains in the tenant context/tenant domains in the central context.
* The access isn't prevented if the request is trying to access a route flagged as 'universal', * The access isn't prevented if the request is trying to access a route flagged as 'universal',
* or if this middleware should be skipped. * or if this middleware should be skipped.
@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
} }
// todo@samuel technically not an identification middleware but probably ok to keep this here
public function requestHasTenant(Request $request): bool public function requestHasTenant(Request $request): bool
{ {
// This middleware is special in that it's not an identification middleware
// but still uses some logic from UsableWithEarlyIdentification, so we just
// need to implement this method here. It doesn't matter what it returns.
return false; return false;
} }
} }

View file

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

View file

@ -13,7 +13,7 @@ class CentralResourceNotAvailableInPivotException extends Exception
parent::__construct( parent::__construct(
'Central resource is not accessible in pivot model. 'Central resource is not accessible in pivot model.
To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching).
To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' To make this work both ways, you can make your pivot implement PivotWithCentralResource and return the related model in getCentralResourceClass() or extend MorphPivot.'
); );
} }
} }

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Events;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\ResourceSyncing\Syncable;
class SyncedResourceDeleted
{
public function __construct(
public Syncable&Model $model,
public TenantWithDatabase|null $tenant,
public bool $forceDelete,
) {}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Listeners\QueueableListener;
/**
* Cleans up pivot records related to the deleted tenant.
*
* The listener only cleans up the pivot tables specified
* in the $pivotTables property (see the property for details),
* and is intended for use with tables that do not have tenant
* foreign key constraints with onDelete('cascade').
*/
class DeleteAllTenantMappings extends QueueableListener
{
public static bool $shouldQueue = false;
/**
* Pivot tables to clean up after a tenant is deleted, in the
* ['table_name' => 'tenant_key_column'] format.
*
* Since we cannot automatically detect which pivot tables
* are being used, they have to be specified here manually.
*
* The default value follows the polymorphic table used by default.
*/
public static array $pivotTables = ['tenant_resources' => 'tenant_id'];
public function handle(TenantDeleted $event): void
{
foreach (static::$pivotTables as $table => $tenantKeyColumn) {
DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->delete();
}
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing\Listeners;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Syncable;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
/**
* Deletes pivot records when a synced resource is deleted.
*
* If a SyncMaster (central resource) is deleted, all pivot records for that resource are deleted.
* If a Syncable (tenant resource) is deleted, only delete the pivot record for that tenant.
*/
class DeleteResourceMapping extends QueueableListener
{
public static bool $shouldQueue = false;
public function handle(SyncedResourceDeleted $event): void
{
$centralResource = $this->getCentralResource($event->model);
if (! $centralResource) {
return;
}
// Delete pivot records if the central resource doesn't use soft deletes
// or the central resource was deleted using forceDelete()
if ($event->forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) {
Pivot::withoutEvents(function () use ($centralResource, $event) {
// If detach() is called with null -- if $event->tenant is null -- this means a central resource was deleted and detaches all tenants.
// If detach() is called with a specific tenant, it means the resource was deleted in that tenant, and we only delete that single mapping.
$centralResource->tenants()->detach($event->tenant);
});
}
}
public function getCentralResource(Syncable&Model $resource): SyncMaster|null
{
if ($resource instanceof SyncMaster) {
return $resource;
}
$centralResourceClass = $resource->getCentralModelName();
/** @var (SyncMaster&Model)|null $centralResource */
$centralResource = $centralResourceClass::firstWhere(
$resource->getGlobalIdentifierKeyName(),
$resource->getGlobalIdentifierKey()
);
return $centralResource;
}
}

View file

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

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
interface PivotWithCentralResource
{
/** @return class-string<\Illuminate\Database\Eloquent\Model&Syncable> */
public function getCentralResourceClass(): string;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\ResourceSyncing;
use Illuminate\Database\Eloquent\Model;
interface PivotWithRelation
{
/**
* E.g. return $this->users()->getModel().
*/
public function getRelatedModel(): Model;
}

View file

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

View file

@ -25,7 +25,5 @@ interface SyncMaster extends Syncable
public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void; public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void;
public function triggerDeleteEvent(bool $forceDelete = false): void; public function triggerRestoreEvent(): void;
public function triggerRestoredEvent(): void;
} }

View file

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

View file

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

View file

@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch;
use Illuminate\Support\Traits\Macroable; use Illuminate\Support\Traits\Macroable;
use Stancl\Tenancy\Concerns\DealsWithRouteContexts; use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
use Stancl\Tenancy\Concerns\ManagesRLSPolicies; use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
@ -24,11 +25,15 @@ class Tenancy
*/ */
public Tenant|null $tenant = null; public Tenant|null $tenant = null;
// todo@docblock /**
* Custom callback for providing a list of bootstrappers to use.
* When this is null, config('tenancy.bootstrappers') is used.
* @var ?Closure(): list<TenancyBootstrapper>
*/
public ?Closure $getBootstrappersUsing = null; public ?Closure $getBootstrappersUsing = null;
/** Is tenancy fully initialized? */ /** Is tenancy fully initialized? */
public bool $initialized = false; // todo@docs document the difference between $tenant being set and $initialized being true (e.g. end of initialize() method) public bool $initialized = false;
/** /**
* List of relations to eager load when fetching a tenant via tenancy()->find(). * List of relations to eager load when fetching a tenant via tenancy()->find().
@ -36,7 +41,7 @@ class Tenancy
public static array $findWith = []; public static array $findWith = [];
/** /**
* A list of bootstrappers that have been initialized. * List of bootstrappers that have been initialized.
* *
* This is used when reverting tenancy, mainly if an exception * This is used when reverting tenancy, mainly if an exception
* occurs during bootstrapping, to ensure we don't revert * occurs during bootstrapping, to ensure we don't revert
@ -49,6 +54,23 @@ class Tenancy
*/ */
public array $initializedBootstrappers = []; public array $initializedBootstrappers = [];
/**
* List of features that have been bootstrapped.
*
* Since features may be bootstrapped multiple times during
* the request cycle (in TSP::boot() and any other times the user calls
* bootstrapFeatures()), we keep track of which features have already
* been bootstrapped so we do not bootstrap them again. Features are
* bootstrapped once and irreversible.
*
* The main point of this is that some features *need* to be bootstrapped
* very early (see #949), so we bootstrap them directly in TSP, but we
* also need the ability to *change* which features are used at runtime
* (mainly tests of this package) and bootstrap features again after making
* changes to config('tenancy.features').
*/
protected array $bootstrappedFeatures = [];
/** Initialize tenancy for the passed tenant. */ /** Initialize tenancy for the passed tenant. */
public function initialize(Tenant|int|string $tenant): void public function initialize(Tenant|int|string $tenant): void
{ {
@ -117,10 +139,12 @@ class Tenancy
return; return;
} }
// We fire both of these events before unsetting tenant so that listeners
// to both events can access the current tenant. Having separate events
// still has value as it's consistent with our other events and provides
// more granularity for event listeners, e.g. for ensuring something runs
// before standard TenancyEnded listeners such as RevertToCentralContext.
event(new Events\EndingTenancy($this)); event(new Events\EndingTenancy($this));
// todo@samuel find a way to refactor these two methods
event(new Events\TenancyEnded($this)); event(new Events\TenancyEnded($this));
$this->tenant = null; $this->tenant = null;
@ -128,15 +152,35 @@ class Tenancy
$this->initialized = false; $this->initialized = false;
} }
/**
* End tenancy and initialize it again for the current tenant.
*
* This can be helpful when changing "dependencies" of bootstrappers such as
* attributes of the current tenant that are only read once, during bootstrap().
*
* If tenancy is not initialized, this method is a no-op.
*/
public function reinitialize(): void
{
if ($this->tenant === null) {
return;
}
$tenant = $this->tenant;
$this->end();
$this->initialize($tenant);
}
/** @return TenancyBootstrapper[] */ /** @return TenancyBootstrapper[] */
public function getBootstrappers(): array public function getBootstrappers(): array
{ {
// If no callback for getting bootstrappers is set, we just return all of them. // If no callback for getting bootstrappers is set, we return the ones in config.
$resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) { $resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) {
return config('tenancy.bootstrappers'); return config('tenancy.bootstrappers');
}; };
// Here We instantiate the bootstrappers and return them. // Here we instantiate the bootstrappers and return them.
return array_map('app', $resolve($this->tenant)); return array_map('app', $resolve($this->tenant));
} }
@ -150,6 +194,26 @@ class Tenancy
return in_array($bootstrapper, static::getBootstrappers(), true); return in_array($bootstrapper, static::getBootstrappers(), true);
} }
/**
* Bootstrap configured Tenancy features.
*
* Normally, features are bootstrapped directly in TSP::boot(). However, if
* new features are enabled at runtime (e.g. during tests), this method may
* be called to bootstrap new features. It's idempotent and keeps track of
* which features have already been bootstrapped. Keep in mind that feature
* bootstrapping is irreversible.
*/
public function bootstrapFeatures(): void
{
foreach (config('tenancy.features') ?? [] as $feature) {
/** @var class-string<Feature> $feature */
if (! in_array($feature, $this->bootstrappedFeatures)) {
app($feature)->bootstrap();
$this->bootstrappedFeatures[] = $feature;
}
}
}
/** /**
* @return Builder<Tenant&Model> * @return Builder<Tenant&Model>
*/ */

View file

@ -6,6 +6,7 @@ namespace Stancl\Tenancy;
use Closure; use Closure;
use Illuminate\Cache\CacheManager; use Illuminate\Cache\CacheManager;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Routing\Events\RouteMatched; use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -40,15 +41,6 @@ class TenancyServiceProvider extends ServiceProvider
// Make sure Tenancy is stateful. // Make sure Tenancy is stateful.
$this->app->singleton(Tenancy::class); $this->app->singleton(Tenancy::class);
// Make sure features are bootstrapped as soon as Tenancy is instantiated.
$this->app->extend(Tenancy::class, function (Tenancy $tenancy) {
foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) {
$this->app[$feature]->bootstrap($tenancy);
}
return $tenancy;
});
// Make it possible to inject the current tenant by type hinting the Tenant contract. // Make it possible to inject the current tenant by type hinting the Tenant contract.
$this->app->bind(Tenant::class, function ($app) { $this->app->bind(Tenant::class, function ($app) {
return $app[Tenancy::class]->tenant; return $app[Tenancy::class]->tenant;
@ -128,6 +120,7 @@ class TenancyServiceProvider extends ServiceProvider
Commands\MigrateFresh::class, Commands\MigrateFresh::class,
Commands\ClearPendingTenants::class, Commands\ClearPendingTenants::class,
Commands\CreatePendingTenants::class, Commands\CreatePendingTenants::class,
Commands\PurgeImpersonationTokens::class,
Commands\CreateUserWithRLSPolicies::class, Commands\CreateUserWithRLSPolicies::class,
]); ]);
@ -165,17 +158,23 @@ class TenancyServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/../assets/routes.php'); $this->loadRoutesFrom(__DIR__ . '/../assets/routes.php');
} }
$this->app->singleton('globalUrl', function ($app) { $this->app->singleton('globalUrl', function (Container $app) {
if ($app->bound(FilesystemTenancyBootstrapper::class)) { if ($app->bound(FilesystemTenancyBootstrapper::class)) {
$instance = clone $app['url']; /** @var \Illuminate\Routing\UrlGenerator */
$instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalAssetUrl); $instance = clone $app->make('url');
$instance->useAssetOrigin($app->make(FilesystemTenancyBootstrapper::class)->originalAssetUrl);
} else { } else {
$instance = $app['url']; $instance = $app->make('url');
} }
return $instance; return $instance;
}); });
// Bootstrap features that are already enabled in the config.
// If more features are enabled at runtime, this method may be called
// multiple times, it keeps track of which features have already been bootstrapped.
$this->app->make(Tenancy::class)->bootstrapFeatures();
Route::middlewareGroup('clone', []); Route::middlewareGroup('clone', []);
Route::middlewareGroup('universal', []); Route::middlewareGroup('universal', []);
Route::middlewareGroup('tenant', []); Route::middlewareGroup('tenant', []);

View file

@ -9,7 +9,7 @@ use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/** /**
* Generates a UUID for the tenant key. * Generates a ULID for the tenant key.
*/ */
class ULIDGenerator implements UniqueIdentifierGenerator class ULIDGenerator implements UniqueIdentifierGenerator
{ {

View file

@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
/** /**
* Generates a UUID for the tenant key. * Generates a UUIDv4 for the tenant key.
*/ */
class UUIDGenerator implements UniqueIdentifierGenerator class UUIDGenerator implements UniqueIdentifierGenerator
{ {

View file

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

View file

@ -36,7 +36,12 @@ if (! function_exists('tenant')) {
} }
if (! function_exists('tenant_asset')) { if (! function_exists('tenant_asset')) {
// todo@docblock /**
* Generate a URL to an asset in tenant storage.
*
* If app.asset_url is set, this helper suffixes that URL before appending the asset path.
* If it is not set, the stancl.tenancy.asset route is used.
*/
function tenant_asset(string|null $asset): string function tenant_asset(string|null $asset): string
{ {
if ($assetUrl = config('app.asset_url')) { if ($assetUrl = config('app.asset_url')) {

103
static_properties.nu Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env nu
# Utility for exporting static properties used for configuration
def main []: nothing -> string {
"See --help for subcommands"
}
# The current number of config static properties in the codebase
def "main count" [...paths: string]: nothing -> int {
props ...$paths | length
}
# Available static properties, grouped by file, rendered as a table
def "main table" [...paths: string]: nothing -> string {
props ...$paths | table --theme rounded --expand
}
# Plain text version of available static properties
def "main plain" [...paths: string]: nothing -> string {
props ...$paths
| each { $"// File: ($in.file)\n($in.props | str join "\n\n")"}
| str join "\n//------------------------------------------------------------\n\n"
}
# Expressive Code formatting of available static properties, used in docs
def "main docs" [...paths: string]: nothing -> string {
(("{/* GENERATED_BEGIN */}\n" + (props ...$paths
| each { update props { each { if ($in | str ends-with "= [") {
$"($in)/* ... */];"
} else { $in }}}}
| each { $"```php /public static .*$/\n// File: ($in.file)\n($in.props | str join "\n\n")\n```"}
| str join "\n\n"))
+ "\n{/* GENERATED_END */}")
}
def props [...paths: string]: nothing -> table<file: string, props: list<string>> {
ls ...(if ($paths | length) > 0 {
($paths | each {|path|
if ($path | str contains "*") {
# already a glob expr
$path | into glob
} else if ($path | str ends-with ".php") {
# src/Foo/Bar.php
$path
} else {
# just 'src/Foo' passed
$"($path)/**/*.php" | into glob
}
})
} else {
[("src/**/*.php" | into glob)]
})
| each { { name: $in.name, content: (open $in.name) } }
| find -nr 'public static (?!.*function)'
| par-each {|file|
let lines = $file.content | lines
mut docblock_start = 0
mut docblock_end = 0
mut props = []
for line in ($lines | enumerate) {
if ($line.item | str contains "/**") {
$docblock_start = $line.index
}
if ($line.item | str contains "@internal") {
# Docblocks with @internal are ignored
$docblock_start = 0
$docblock_end = 0
}
if ($line.item | str contains "*/") {
$docblock_end = $line.index
}
if (
(
( # Valid (non-internal) docblock
$docblock_start != 0 and
$docblock_end != 0 and
$docblock_end == ($line.index - 1)
) or
( # No docblock
$line.index != 0 and
(($lines | get ($line.index - 1)) | str index-of "*/") == -1
)
) and
($line.item | str trim | str index-of "public static") == 0 and
($line.item | str trim | str index-of "public static function") == -1
) {
if ($docblock_start == 0) or ($docblock_end == 0) or ($docblock_end != ($line.index - 1)) {
$docblock_start = $line.index
$docblock_end = $line.index
}
$props = $props | append ($lines | slice $docblock_start..$line.index | each { str trim } | str join "\n")
$docblock_start = 0
$docblock_end = 0
}
}
{file: $file.name, props: $props}
}
| where ($it.props | length) > 0
}

2
t
View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
if [[ "${CLAUDECODE}" != "1" ]]; then if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always" COLOR_FLAG="--colors=always"

2
test
View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
if [[ "${CLAUDECODE}" != "1" ]]; then if [[ "${CLAUDECODE}" != "1" ]]; then
COLOR_FLAG="--colors=always" COLOR_FLAG="--colors=always"

View file

@ -18,8 +18,6 @@ beforeEach(function () {
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
}); });
// todo@move move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests"
test('create storage symlinks action works', function() { test('create storage symlinks action works', function() {
config([ config([
'tenancy.bootstrappers' => [ 'tenancy.bootstrappers' => [

View file

@ -103,6 +103,33 @@ test('central helper doesnt change tenancy state when called in central context'
expect(tenant())->toBeNull(); expect(tenant())->toBeNull();
}); });
test('reinitialize method does nothing in the central context', function () {
expect(tenancy()->initialized)->toBe(false);
expect(fn () => tenancy()->reinitialize())->not()->toThrow(\Throwable::class);
expect(tenancy()->initialized)->toBe(false);
});
test('reinitialize method runs bootstrappers again for the current tenant', function () {
config(['tenancy.bootstrappers' => [
ReinitBootstrapper::class,
]]);
tenancy()->initialize($tenant = Tenant::create(['reinit_bootstrapper_key' => 'foo']));
expect(tenant()->getKey())->toBe($tenant->getKey());
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
$tenant->update(['reinit_bootstrapper_key' => 'bar']);
// Unchanged until we reinitialize...
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo');
tenancy()->reinitialize();
expect(tenant()->getKey())->toBe($tenant->getKey());
expect(app('tenancy_reinit_bootstrapper_key'))->toBe('bar');
});
class MyBootstrapper implements TenancyBootstrapper class MyBootstrapper implements TenancyBootstrapper
{ {
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
@ -115,3 +142,16 @@ class MyBootstrapper implements TenancyBootstrapper
app()->instance('tenancy_ended', true); app()->instance('tenancy_ended', true);
} }
} }
class ReinitBootstrapper implements TenancyBootstrapper
{
public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void
{
app()->instance('tenancy_reinit_bootstrapper_key', $tenant->getAttribute('reinit_bootstrapper_key'));
}
public function revert(): void
{
app()->instance('tenancy_reinit_bootstrapper_key', null);
}
}

View file

@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() {
test('storage_path helper does not change if suffix_storage_path is off', function() { test('storage_path helper does not change if suffix_storage_path is off', function() {
$originalStoragePath = storage_path(); $originalStoragePath = storage_path();
// todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362
config([ config([
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
'tenancy.filesystem.suffix_storage_path' => false, 'tenancy.filesystem.suffix_storage_path' => false,
@ -202,3 +200,60 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen
expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($tenantStoragePath))->toBeFalse();
}); });
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath
]);
$centralStoragePath = storage_path();
tenancy()->initialize($tenant = Tenant::create());
if ($suffixStoragePath) {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . "/tenant{$tenant->id}/framework/cache");
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeTrue();
} else {
expect(storage_path('framework/cache'))->toBe($centralStoragePath . '/framework/cache');
expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse();
}
})->with([true, false]);
test('scoped disks are scoped per tenant', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'filesystems.disks.scoped_disk' => [
'driver' => 'scoped',
'disk' => 'public',
'prefix' => 'scoped_disk_prefix',
],
]);
$tenant = Tenant::create();
Storage::disk('scoped_disk')->put('foo.txt', 'central');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central');
tenancy()->initialize($tenant);
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null);
Storage::disk('scoped_disk')->put('foo.txt', 'tenant');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant');
tenancy()->end();
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
Storage::disk('scoped_disk')->put('foo.txt', 'central2');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2');
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
});

View file

@ -2,6 +2,7 @@
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\UrlGenerator; use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\URL;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -18,7 +19,6 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
@ -26,12 +26,16 @@ beforeEach(function () {
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false;
TenancyUrlGenerator::$overrides = [];
TenancyUrlGenerator::$bypassParameter = 'central';
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
}); });
afterEach(function () { afterEach(function () {
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false;
TenancyUrlGenerator::$overrides = [];
TenancyUrlGenerator::$bypassParameter = 'central';
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
}); });
@ -80,6 +84,44 @@ test('tenancy url generator can prefix route names passed to the route helper',
expect(route('home'))->toBe('http://localhost/central/home'); expect(route('home'))->toBe('http://localhost/central/home');
}); });
test('tenancy url generator inherits scheme from original url generator', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/home', fn () => '')->name('home');
// No scheme forced, default is HTTP
expect(app('url')->formatScheme())->toBe('http://');
$tenant = Tenant::create();
// Force the original URL generator to use HTTPS
app('url')->forceScheme('https');
// Original generator uses HTTPS
expect(app('url')->formatScheme())->toBe('https://');
// Check that TenancyUrlGenerator inherits the HTTPS scheme
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('https://'); // Should inherit HTTPS
expect(route('home'))->toBe('https://localhost/home');
tenancy()->end();
// After ending tenancy, the original generator should still have the original scheme (HTTPS)
expect(route('home'))->toBe('https://localhost/home');
// Use HTTP scheme
app('url')->forceScheme('http');
expect(app('url')->formatScheme())->toBe('http://');
tenancy()->initialize($tenant);
expect(app('url')->formatScheme())->toBe('http://'); // Should inherit scheme (HTTP)
expect(route('home'))->toBe('http://localhost/home');
tenancy()->end();
expect(route('home'))->toBe('http://localhost/home');
});
test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
@ -322,3 +364,40 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b
expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl) expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl)
->not()->toContain('bypassParameter'); ->not()->toContain('bypassParameter');
}); });
test('the temporarySignedRoute method can automatically prefix the passed route name', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/{tenant}/foo', fn () => 'foo')->name('tenant.foo')->middleware([InitializeTenancyByPath::class]);
TenancyUrlGenerator::$prefixRouteNames = true;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
// Route name ('foo') gets prefixed automatically (will be 'tenant.foo')
$tenantSignedUrl = URL::temporarySignedRoute('foo', now()->addMinutes(2), ['tenant' => $tenantKey = $tenant->getTenantKey()]);
expect($tenantSignedUrl)->toContain("localhost/{$tenantKey}/foo");
});
test('the bypass parameter works correctly with temporarySignedRoute', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/foo', fn () => 'foo')->name('central.foo');
TenancyUrlGenerator::$prefixRouteNames = true;
TenancyUrlGenerator::$bypassParameter = 'central';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
// Bypass parameter allows us to generate URL for the 'central.foo' route in tenant context
$centralSignedUrl = URL::temporarySignedRoute('central.foo', now()->addMinutes(2), ['central' => true]);
expect($centralSignedUrl)
->toContain('localhost/foo')
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
});

View file

@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst
false, false,
]); ]);
test('the clone action prefixes already prefixed routes correctly', function () { test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) {
$routes = [ $routes = [
RouteFacade::get('/home', fn () => true) RouteFacade::get('/home', fn () => true)
->middleware(['clone']) ->middleware(['clone'])
@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function ()
->prefix('prefix/'), ->prefix('prefix/'),
]; ];
app(CloneRoutesAsTenant::class)->handle(); $cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction
->tenantParameterBeforePrefix($tenantParameterBeforePrefix)
->handle();
$expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
$clonedRoutes = [ $clonedRoutes = [
RouteFacade::getRoutes()->getByName('tenant.home'), RouteFacade::getRoutes()->getByName('tenant.home'),
@ -206,9 +211,10 @@ test('the clone action prefixes already prefixed routes correctly', function ()
// The cloned route is prefixed correctly // The cloned route is prefixed correctly
foreach ($clonedRoutes as $key => $route) { foreach ($clonedRoutes as $key => $route) {
expect($route->getPrefix())->toBe("prefix/{tenant}"); expect($route->getPrefix())->toBe($expectedPrefix);
$clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]); $clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]);
$expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
expect($clonedRouteUrl) expect($clonedRouteUrl)
// Original prefix does not occur in the cloned route's URL // Original prefix does not occur in the cloned route's URL
@ -216,14 +222,14 @@ test('the clone action prefixes already prefixed routes correctly', function ()
->not()->toContain("//prefix") ->not()->toContain("//prefix")
->not()->toContain("prefix//") ->not()->toContain("prefix//")
// Instead, the route is prefixed correctly // Instead, the route is prefixed correctly
->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}"); ->toBe("http://localhost/{$expectedPrefixInUrl}/{$routes[$key]->getName()}");
// The cloned route is accessible // The cloned route is accessible
pest()->get($clonedRouteUrl)->assertOk(); pest()->get($clonedRouteUrl)->assertOk();
} }
}); })->with([true, false]);
test('clone action trims trailing slashes from prefixes given to nested route groups', function () { test('clone action trims trailing slashes from prefixes given to nested route groups', function (bool $tenantParameterBeforePrefix) {
RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('prefix')->group(function () {
RouteFacade::prefix('')->group(function () { RouteFacade::prefix('')->group(function () {
// This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route
@ -237,7 +243,10 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
}); });
}); });
app(CloneRoutesAsTenant::class)->handle(); $cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction
->tenantParameterBeforePrefix($tenantParameterBeforePrefix)
->handle();
$clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]);
$clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]);
@ -245,17 +254,20 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
$landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing'); $landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing');
$homeRoute = RouteFacade::getRoutes()->getByName('tenant.home'); $homeRoute = RouteFacade::getRoutes()->getByName('tenant.home');
expect($landingRoute->uri())->toBe('prefix/{tenant}'); $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}';
expect($homeRoute->uri())->toBe('prefix/{tenant}/home'); $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}";
expect($landingRoute->uri())->toBe($expectedPrefix);
expect($homeRoute->uri())->toBe("{$expectedPrefix}/home");
expect($clonedLandingUrl) expect($clonedLandingUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->id}"); ->toBe("http://localhost/{$expectedPrefixInUrl}");
expect($clonedHomeRouteUrl) expect($clonedHomeRouteUrl)
->not()->toContain("prefix//") ->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->id}/home"); ->toBe("http://localhost/{$expectedPrefixInUrl}/home");
}); })->with([true, false]);
test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () { test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () {
// Should NOT be cloned, already has tenant parameter // Should NOT be cloned, already has tenant parameter
@ -389,3 +401,77 @@ test('tenant parameter addition can be controlled by setting addTenantParameter'
$this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central'); $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
} }
})->with([true, false]); })->with([true, false]);
test('existing context flags are removed during cloning', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone', 'central']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone', 'universal']);
$cloneAction = app(CloneRoutesAsTenant::class);
// Clone foo route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('central');
// Clone bar route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar');
expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo')))
->not()->toContain('universal');
});
test('cloning a route without a prefix or differing domains overrides the original route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('foo');
$cloneAction = CloneRoutesAsTenant::make();
$cloneAction->cloneRoute('foo')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo');
});
test('addTenantMiddleware can be used to specify the tenant middleware for the cloned route', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
$cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction->cloneRoute('foo')->addTenantMiddleware([InitializeTenancyByPath::class])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
$cloned = RouteFacade::getRoutes()->getByName('tenant.foo');
expect($cloned->uri())->toBe('{tenant}/foo');
expect($cloned->getName())->toBe('tenant.foo');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByPath::class]);
$cloneAction->cloneRoute('bar')
->addTenantMiddleware([InitializeTenancyByDomain::class])
->domain('foo.localhost')
->addTenantParameter(false)
->tenantParameterBeforePrefix(false)
->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
$cloned = RouteFacade::getRoutes()->getByName('tenant.bar');
expect($cloned->uri())->toBe('bar');
expect($cloned->getName())->toBe('tenant.bar');
expect($cloned->getDomain())->toBe('foo.localhost');
expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]);
});
test('cloneRoutes can be used to clone multiple routes', function () {
RouteFacade::get('/foo', fn () => true)->name('foo');
$bar = RouteFacade::get('/bar', fn () => true)->name('bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
CloneRoutesAsTenant::make()->cloneRoutes(['foo', $bar])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar');
expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('tenant.baz');
});

View file

@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK
RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']); RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']);
}); });
$tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]); $tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]);
// Migrate users and comments tables on tenant connection // Migrate users and comments tables on tenant connection
pest()->artisan('tenants:migrate', [ pest()->artisan('tenants:migrate', [

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster class CentralUser extends Model implements SyncMaster
{ {
use ResourceSyncing, CentralConnection; use ResourceSyncing, CentralConnection;
protected $guarded = []; protected $guarded = [];
public $timestamps = false; public $timestamps = false;

View file

@ -4,20 +4,13 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing; namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing;
use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Stancl\Tenancy\ResourceSyncing\PivotWithRelation;
use Stancl\Tenancy\ResourceSyncing\TenantPivot; use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomPivot extends TenantPivot implements PivotWithRelation class CustomPivot extends TenantPivot implements PivotWithCentralResource
{ {
public function users(): BelongsToMany public function getCentralResourceClass(): string
{ {
return $this->belongsToMany(CentralUser::class); return CentralUser::class;
}
public function getRelatedModel(): Model
{
return $this->users()->getModel();
} }
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc; namespace Stancl\Tenancy\Tests\Etc;
use Closure;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasDomains;
@ -16,6 +17,7 @@ use Stancl\Tenancy\Database\Models;
class Tenant extends Models\Tenant implements TenantWithDatabase class Tenant extends Models\Tenant implements TenantWithDatabase
{ {
public static array $extraCustomColumns = []; public static array $extraCustomColumns = [];
public static ?Closure $getPendingAttributesUsing = null;
use HasDatabase, HasDomains, HasPending; use HasDatabase, HasDomains, HasPending;
@ -23,4 +25,9 @@ class Tenant extends Models\Tenant implements TenantWithDatabase
{ {
return array_merge(parent::getCustomColumns(), static::$extraCustomColumns); return array_merge(parent::getCustomColumns(), static::$extraCustomColumns);
} }
public static function getPendingAttributes(array $attributes): array
{
return static::$getPendingAttributesUsing ? (static::$getPendingAttributesUsing)($attributes) : [];
}
} }

View file

@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration
$table->string('global_user_id'); $table->string('global_user_id');
$table->unique(['tenant_id', 'global_user_id']); $table->unique(['tenant_id', 'global_user_id']);
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade');
}); });
} }

View file

@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
return json_encode(DB::select(request('q2'))); return json_encode(DB::select(request('q2')));
}); });
tenancy(); // trigger features: todo@samuel remove after feature refactor tenancy()->bootstrapFeatures();
if ($disallow) { if ($disallow) {
expect(fn () => pest()->post('/central-sqli', [ expect(fn () => pest()->post('/central-sqli', [

View file

@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () {
'tenancy.features' => [CrossDomainRedirect::class], 'tenancy.features' => [CrossDomainRedirect::class],
]); ]);
tenancy()->bootstrapFeatures();
Route::get('/foobar', function () { Route::get('/foobar', function () {
return 'Foo'; return 'Foo';
})->name('home'); })->name('home');

View file

@ -2,29 +2,27 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Features\TenantConfig;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withBootstrapping;
beforeEach(function () {
config([
'tenancy.bootstrappers' => [TenantConfigBootstrapper::class],
]);
withBootstrapping();
});
afterEach(function () { afterEach(function () {
TenantConfig::$storageToConfigMap = []; TenantConfigBootstrapper::$storageToConfigMap = [];
}); });
test('nested tenant values are merged', function () { test('nested tenant values are merged', function () {
expect(config('whitelabel.theme'))->toBeNull(); expect(config('whitelabel.theme'))->toBeNull();
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'whitelabel.config.theme' => 'whitelabel.theme', 'whitelabel.config.theme' => 'whitelabel.theme',
]; ];
@ -39,14 +37,8 @@ test('nested tenant values are merged', function () {
test('config is merged and removed', function () { test('config is merged and removed', function () {
expect(config('services.paypal'))->toBe(null); expect(config('services.paypal'))->toBe(null);
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'paypal_api_public' => 'services.paypal.public', 'paypal_api_public' => 'services.paypal.public',
'paypal_api_private' => 'services.paypal.private', 'paypal_api_private' => 'services.paypal.private',
]; ];
@ -68,14 +60,8 @@ test('config is merged and removed', function () {
test('the value can be set to multiple config keys', function () { test('the value can be set to multiple config keys', function () {
expect(config('services.paypal'))->toBe(null); expect(config('services.paypal'))->toBe(null);
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenantConfig::$storageToConfigMap = [ TenantConfigBootstrapper::$storageToConfigMap = [
'paypal_api_public' => [ 'paypal_api_public' => [
'services.paypal.public1', 'services.paypal.public1',
'services.paypal.public2', 'services.paypal.public2',

View file

@ -27,6 +27,7 @@ beforeEach(function () {
test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () {
config(['tenancy.features' => [ViteBundler::class]]); config(['tenancy.features' => [ViteBundler::class]]);
tenancy()->bootstrapFeatures();
withBootstrapping(); withBootstrapping();

View file

@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled;
use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
@ -38,18 +40,46 @@ test('tenants can be in maintenance mode', function () {
pest()->get('http://acme.localhost/foo')->assertStatus(200); pest()->get('http://acme.localhost/foo')->assertStatus(200);
}); });
test('maintenance mode events are fired', function () { test('maintenance mode middleware can be used with universal routes', function () {
$tenant = MaintenanceTenant::create(); Route::get('/foo', function () {
return 'bar';
})->middleware(['universal', InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]);
Event::fake(); $tenant = MaintenanceTenant::create();
$tenant->domains()->create([
'domain' => 'acme.localhost',
]);
// Revert to central context after each request so that the tenant context
// from the request doesn't persist
$run = function (Closure $callback) { $callback(); tenancy()->end(); };
$run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200));
$tenant->putDownForMaintenance(); $tenant->putDownForMaintenance();
Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class); $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(503));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); // Not affected by a tenant's maintenance mode
$tenant->bringUpFromMaintenance(); $tenant->bringUpFromMaintenance();
Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class); $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200));
$run(fn () => pest()->get('http://localhost/foo')->assertStatus(200));
});
test('maintenance mode events are fired', function () {
$tenant = MaintenanceTenant::create();
Event::fake([TenantMaintenanceModeEnabled::class, TenantMaintenanceModeDisabled::class]);
$tenant->putDownForMaintenance();
Event::assertDispatched(TenantMaintenanceModeEnabled::class);
$tenant->bringUpFromMaintenance();
Event::assertDispatched(TenantMaintenanceModeDisabled::class);
}); });
test('tenants can be put into maintenance mode using artisan commands', function() { test('tenants can be put into maintenance mode using artisan commands', function() {

View file

@ -2,8 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Stancl\Tenancy\Commands\ClearPendingTenants; use Stancl\Tenancy\Commands\ClearPendingTenants;
use Stancl\Tenancy\Commands\CreatePendingTenants; use Stancl\Tenancy\Commands\CreatePendingTenants;
use Stancl\Tenancy\Events\CreatingPendingTenant; use Stancl\Tenancy\Events\CreatingPendingTenant;
@ -13,6 +17,13 @@ use Stancl\Tenancy\Events\PullingPendingTenant;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
beforeEach($cleanup = function () {
Tenant::$extraCustomColumns = [];
Tenant::$getPendingAttributesUsing = null;
});
afterEach($cleanup);
test('tenants are correctly identified as pending', function (){ test('tenants are correctly identified as pending', function (){
Tenant::createPending(); Tenant::createPending();
@ -191,3 +202,25 @@ test('commands run for pending tenants too if the with pending option is passed'
$artisan->assertExitCode(0); $artisan->assertExitCode(0);
}); });
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
Tenant::$extraCustomColumns = ['slug'];
if ($withPendingAttributes) Tenant::$getPendingAttributesUsing = fn () => [
'slug' => Str::random(8),
];
$fn = fn () => Tenant::createPending();
// If there are non-nullable custom columns, and createPending() is called
// on its own without any values passed for those columns (as it would be called
// by the tenants:pending-create artisan command), we expect it to fail, unless
// getPendingAttributes() provides default values for those custom columns.
if ($withPendingAttributes)
expect($fn)->not()->toThrow(QueryException::class);
else
expect($fn)->toThrow(QueryException::class);
})->with([true, false]);

View file

@ -17,6 +17,7 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Tenancy;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
beforeEach(function () { beforeEach(function () {
@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class, TraitRLSManager::class,
]); ]);
test('dropRLSPolicies only drops RLS policies', function () {
DB::statement('CREATE POLICY "comments_dummy_rls_policy" ON comments USING (true)');
DB::statement('CREATE POLICY "comments_foo_policy" ON comments USING (true)'); // non-RLS policy
$policyCount = fn () => count(DB::select("SELECT policyname FROM pg_policies WHERE tablename = 'comments'"));
expect($policyCount())->toBe(2);
$removed = Tenancy::dropRLSPolicies('comments');
expect($removed)->toBe(1);
// Only the non-RLS policy remains
expect($policyCount())->toBe(1);
});
test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) { test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls; CreateUserWithRLSPolicies::$forceRls = $forceRls;

View file

@ -46,6 +46,13 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings;
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping;
use Illuminate\Database\Eloquent\Relations\Relation;
beforeEach(function () { beforeEach(function () {
config(['tenancy.bootstrappers' => [ config(['tenancy.bootstrappers' => [
@ -69,6 +76,7 @@ beforeEach(function () {
CreateTenantResource::$shouldQueue = false; CreateTenantResource::$shouldQueue = false;
DeleteResourceInTenant::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id'];
// Reset global scopes on models (should happen automatically but to make this more explicit) // Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels(); Model::clearBootedModels();
@ -92,6 +100,7 @@ beforeEach(function () {
CentralUser::$creationAttributes = $creationAttributes; CentralUser::$creationAttributes = $creationAttributes;
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class);
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
@ -255,7 +264,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant
expect(TenantUser::all())->toHaveCount(0); expect(TenantUser::all())->toHaveCount(0);
}); });
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($createCentralUser()); $tenant->customPivotUsers()->attach($createCentralUser());
$createCentralUser()->tenants()->attach($tenant); $createCentralUser()->tenants()->attach($tenant);
@ -279,7 +288,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
migrateUsersTableForTenants(); migrateUsersTableForTenants();
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->attach($centralUser); $tenant->customPivotUsers()->attach($centralUser);
} else { } else {
$centralUser->tenants()->attach($tenant); $centralUser->tenants()->attach($tenant);
@ -290,7 +299,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
}); });
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUser); $tenant->customPivotUsers()->detach($centralUser);
} else { } else {
$centralUser->tenants()->detach($tenant); $centralUser->tenants()->detach($tenant);
@ -325,7 +334,7 @@ test('detaching central users from tenants or vice versa force deletes the synce
}); });
if ($attachUserToTenant) { if ($attachUserToTenant) {
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface
$tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes);
} else { } else {
$centralUserWithSoftDeletes->tenants()->detach($tenant); $centralUserWithSoftDeletes->tenants()->detach($tenant);
@ -890,7 +899,54 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $
'basic pivot' => false, 'basic pivot' => false,
]); ]);
test('tenant pivot records are deleted along with the tenants to which they belong to', function() { test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) {
[$tenant] = createTenantsAndRunMigrations();
if ($morphPivot) {
config(['tenancy.models.tenant' => MorphTenant::class]);
$centralUserModel = BaseCentralUser::class;
// The default pivot table, no need to configure the listener
$pivotTable = 'tenant_resources';
} else {
$centralUserModel = CentralUser::class;
// Custom pivot table
$pivotTable = 'tenant_users';
}
if ($dbLevelOnCascadeDelete) {
addTenantIdConstraintToPivot($pivotTable);
} else {
// Event-based cleanup
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id'];
}
$syncMaster = $centralUserModel::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$syncMaster->tenants()->attach($tenant);
// Pivot records should be deleted along with the tenant
$tenant->delete();
expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
})->with([
'db level on cascade delete' => true,
'event-based on cascade delete' => false,
])->with([
'polymorphic pivot' => true,
'basic pivot' => false,
]);
test('pivot record is automatically deleted with the tenant resource', function() {
[$tenant] = createTenantsAndRunMigrations(); [$tenant] = createTenantsAndRunMigrations();
$syncMaster = CentralUser::create([ $syncMaster = CentralUser::create([
@ -903,10 +959,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo
$syncMaster->tenants()->attach($tenant); $syncMaster->tenants()->attach($tenant);
$tenant->delete(); expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
// Deleting tenant deletes its pivot records $tenant->run(function () {
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); TenantUser::firstWhere('global_id', 'cascade_user')->delete();
});
// Deleting tenant resource deletes its pivot record
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
// The same works with forceDelete
addExtraColumns(true);
$syncMaster = CentralUserWithSoftDeletes::create([
'global_id' => 'force_cascade_user',
'name' => 'Central user',
'email' => 'central2@localhost',
'password' => 'password',
'role' => 'force_cascade_user',
'foo' => 'bar',
]);
$syncMaster->tenants()->attach($tenant);
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1);
$tenant->run(function () {
TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete();
});
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0);
});
test('DeleteAllTenantMappings handles incorrect configuration correctly', function() {
Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class);
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
// Existing table, non-existent tenant key column
// The listener should throw an 'unknown column' exception
DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column'];
// Should throw an exception when tenant is deleted
expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'");
// Non-existent table
DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column'];
expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist");
}); });
test('trashed resources are synced correctly', function () { test('trashed resources are synced correctly', function () {
@ -1265,6 +1365,60 @@ test('global scopes on syncable models can break resource syncing', function ()
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
}); });
test('attach and detach events are handled correctly when using morph maps', function() {
config(['tenancy.models.tenant' => MorphTenant::class]);
[$tenant] = createTenantsAndRunMigrations();
migrateCompaniesTableForTenants();
Relation::morphMap([
'users' => BaseCentralUser::class,
'companies' => CentralCompany::class,
]);
$centralUser = BaseCentralUser::create([
'global_id' => 'user',
'name' => 'Central user',
'email' => 'central@localhost',
'password' => 'password',
'role' => 'user',
]);
$centralCompany = CentralCompany::create([
'global_id' => 'company',
'name' => 'Central company',
'email' => 'company@localhost',
]);
$tenant->users()->attach($centralUser);
$tenant->companies()->attach($centralCompany);
// Assert all tenant_resources mappings actually use the configured morph map
expect(DB::table('tenant_resources')->count())
->toBe(DB::table('tenant_resources')->whereIn('tenant_resources_type', ['users', 'companies'])->count());
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->not()->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->not()->toBeNull();
tenancy()->end();
$tenant->users()->detach($centralUser);
$tenant->companies()->detach($centralCompany);
tenancy()->initialize($tenant);
expect(BaseTenantUser::whereGlobalId('user')->first())->toBeNull();
expect(TenantCompany::whereGlobalId('company')->first())->toBeNull();
});
function addTenantIdConstraintToPivot(string $pivotTable): void
{
Schema::table($pivotTable, function (Blueprint $table) {
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
/** /**
* Create two tenants and run migrations for those tenants. * Create two tenants and run migrations for those tenants.
* *

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains; use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
// todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup // todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup
@ -56,6 +57,7 @@ test('file sessions are separated', function (bool $scopeSessions) {
if ($scopeSessions) { if ($scopeSessions) {
expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions'));
expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue();
} else { } else {
expect($sessionPath())->toBe(storage_path('framework/sessions')); expect($sessionPath())->toBe(storage_path('framework/sessions'));
} }
@ -99,7 +101,7 @@ test('redis sessions are separated using the redis bootstrapper', function (bool
expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled); expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled);
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_"); return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_"));
}))->toHaveCount($bootstrappedEnabled ? 1 : 0); }))->toHaveCount($bootstrappedEnabled ? 1 : 0);
})->with([true, false]); })->with([true, false]);
@ -117,13 +119,13 @@ test('redis sessions are separated using the cache bootstrapper', function (bool
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); pest()->get("/{$tenant->id}/foo");
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
tenancy()->end(); tenancy()->end();
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}"); return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}"));
}))->toHaveCount($scopeSessions ? 1 : 0); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->with([true, false]);
@ -147,14 +149,14 @@ test('memcached sessions are separated using the cache bootstrapper', function (
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); pest()->get("/{$tenant->id}/foo");
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
tenancy()->end(); tenancy()->end();
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock
expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) { expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) {
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
}))->toHaveCount($scopeSessions ? 1 : 0); }))->toHaveCount($scopeSessions ? 1 : 0);
Artisan::call('cache:clear memcached'); Artisan::call('cache:clear memcached');
@ -176,13 +178,13 @@ test('dynamodb sessions are separated using the cache bootstrapper', function (b
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); pest()->get("/{$tenant->id}/foo");
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
tenancy()->end(); tenancy()->end();
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) { expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) {
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
}))->toHaveCount($scopeSessions ? 1 : 0); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->with([true, false]);
@ -201,13 +203,13 @@ test('apc sessions are separated using the cache bootstrapper', function (bool $
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
pest()->get("/{$tenant->id}/foo"); pest()->get("/{$tenant->id}/foo");
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
tenancy()->end(); tenancy()->end();
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) { expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) {
return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
}))->toHaveCount($scopeSessions ? 1 : 0); }))->toHaveCount($scopeSessions ? 1 : 0);
})->with([true, false]); })->with([true, false]);
@ -249,3 +251,13 @@ test('database sessions are separated regardless of whether the session bootstra
// [false, true], // when the connection IS set, the session bootstrapper becomes necessary // [false, true], // when the connection IS set, the session bootstrapper becomes necessary
[false, false], [false, false],
]); ]);
function formatLaravelCacheKey(string $suffix = '', string $prefix = ''): string
{
// todo@release if we drop Laravel 12 support we can just switch to - syntax everywhere
if (version_compare(app()->version(), '13.0.0') >= 0) {
return $prefix . 'laravel-cache-' . $suffix;
} else {
return $prefix . 'laravel_cache_' . $suffix;
}
}

View file

@ -8,6 +8,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper',
$tenant = Tenant::create(); $tenant = Tenant::create();
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
$filename = 'testfile' . pest()->randomString(10); $filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar'); Storage::disk('public')->put($filename, 'bar');
$path = storage_path("app/public/$filename"); $path = storage_path("app/public/$filename");
@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () {
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
$tenant->createDomain('foo.localhost'); $tenant->createDomain('foo.localhost');
$filename = 'testfile' . pest()->randomString(10); $filename = 'testfile' . Str::random(10);
Storage::disk('public')->put($filename, 'bar'); Storage::disk('public')->put($filename, 'bar');
$this->withoutExceptionHandling(); $this->withoutExceptionHandling();

View file

@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withBootstrapping;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () { beforeEach(function () {
SQLiteDatabaseManager::$path = null; SQLiteDatabaseManager::$path = null;
@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
"tenancy.database.managers.$driver" => $databaseManager, "tenancy.database.managers.$driver" => $databaseManager,
]); ]);
$name = 'db' . pest()->randomString(); $name = 'db' . Str::random(10);
$manager = app($databaseManager); $manager = app($databaseManager);
@ -70,7 +72,7 @@ test('dbs can be created when another driver is used for the central db', functi
return $event->tenant; return $event->tenant;
})->toListener()); })->toListener());
$database = 'db' . pest()->randomString(); $database = 'db' . Str::random(10);
$mysqlmanager = app(MySQLDatabaseManager::class); $mysqlmanager = app(MySQLDatabaseManager::class);
$mysqlmanager->setConnection('mysql'); $mysqlmanager->setConnection('mysql');
@ -86,7 +88,7 @@ test('dbs can be created when another driver is used for the central db', functi
$postgresManager = app(PostgreSQLDatabaseManager::class); $postgresManager = app(PostgreSQLDatabaseManager::class);
$postgresManager->setConnection('pgsql'); $postgresManager->setConnection('pgsql');
$database = 'db' . pest()->randomString(); $database = 'db' . Str::random(10);
expect($postgresManager->databaseExists($database))->toBeFalse(); expect($postgresManager->databaseExists($database))->toBeFalse();
Tenant::create([ Tenant::create([
@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () {
expect(database_path('foodb'))->toBe(config('database.connections.tenant.database')); expect(database_path('foodb'))->toBe(config('database.connections.tenant.database'));
}); });
test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) { test('sqlite databases respect the template journal_mode config', function (string $journal_mode) {
$expected = $wal ? 'wal' : 'delete'; withTenantDatabases();
if ($wal !== null) { withBootstrapping();
SQLiteDatabaseManager::$WAL = $wal; config([
} else { 'database.connections.sqlite.journal_mode' => $journal_mode,
// default behavior 'tenancy.bootstrappers' => [
$expected = 'wal'; DatabaseTenancyBootstrapper::class,
} ],
]);
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenancy_db_connection' => 'sqlite', 'tenancy_db_connection' => 'sqlite',
@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null
$db = new PDO('sqlite:' . $dbPath); $db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected); // Before we connect to the DB using Laravel, it will be in default delete mode
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete');
// cleanup // This will trigger the logic in Laravel's SQLiteConnector
SQLiteDatabaseManager::$WAL = true; $tenant->run(fn () => DB::select('select 1'));
})->with([true, false, null]);
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Once we connect to the DB, it will be in the configured journal mode
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode);
})->with(['delete', 'wal']);
test('schema manager uses schema to separate tenant dbs', function () { test('schema manager uses schema to separate tenant dbs', function () {
config([ config([
@ -239,9 +245,6 @@ test('tenant database can be created and deleted on a foreign server', function
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);
@ -287,9 +290,6 @@ test('tenant database can be created on a foreign server by using the host from
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);
@ -327,9 +327,6 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
]); ]);

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
@ -94,6 +95,20 @@ test('ulid ids are supported', function () {
expect($tenant2->id > $tenant1->id)->toBeTrue(); expect($tenant2->id > $tenant1->id)->toBeTrue();
}); });
test('uuidv7 ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, UUIDv7Generator::class);
$tenant1 = Tenant::create();
expect($tenant1->id)->toBeString();
expect(strlen($tenant1->id))->toBe(36);
$tenant2 = Tenant::create();
expect($tenant2->id)->toBeString();
expect(strlen($tenant2->id))->toBe(36);
expect($tenant2->id > $tenant1->id)->toBeTrue();
});
test('hex ids are supported', function () { test('hex ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class);

View file

@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Symfony\Component\HttpKernel\Exception\HttpException;
beforeEach(function () { beforeEach(function () {
pest()->artisan('migrate', [ pest()->artisan('migrate', [
@ -42,6 +43,8 @@ beforeEach(function () {
], ],
]); ]);
tenancy()->bootstrapFeatures();
Event::listen( Event::listen(
TenantCreated::class, TenantCreated::class,
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
@ -292,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function (
->toBeInstanceOf(ImpersonationToken::class); ->toBeInstanceOf(ImpersonationToken::class);
}); });
test('expired tokens are cleaned up before aborting', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make the token expired
$token->update([
'created_at' => Carbon::now()->subSeconds(100),
]);
expect(ImpersonationToken::find($token->token))->not()->toBeNull();
tenancy()->initialize($tenant);
// Try to use the expired token - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('tokens are cleaned up when in wrong tenant context before aborting', function () {
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
migrateTenants();
$user = $tenant1->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
$token = tenancy()->impersonate($tenant1, $user->id, '/dashboard');
expect(ImpersonationToken::find($token->token))->not->toBeNull();
tenancy()->initialize($tenant2);
// Try to use the token in wrong tenant context - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403
expect(ImpersonationToken::find($token->token))->toBeNull();
});
test('expired impersonation tokens can be cleaned up using a command', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});
// Create tokens
$oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
// Make two of the tokens expired by updating their created_at
$oldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
$anotherOldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);
// All tokens exist
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull();
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('2 expired impersonation tokens deleted');
// The expired tokens were deleted
expect(ImpersonationToken::find($oldToken->token))->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull();
// The active token still exists
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
// Update the active token to make it expired according to the default ttl (60s)
$activeToken->update([
'created_at' => Carbon::now()->subSeconds(70),
]);
// With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s)
UserImpersonation::$ttl = 80;
pest()->artisan('tenants:purge-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('0 expired impersonation tokens deleted');
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
});
function migrateTenants() function migrateTenants()
{ {
pest()->artisan('tenants:migrate')->assertExitCode(0); pest()->artisan('tenants:migrate')->assertExitCode(0);

View file

@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest; use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
@ -143,9 +144,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
'database.connections.sqlite.database' => ':memory:', 'database.connections.sqlite.database' => ':memory:',
'database.connections.mysql.charset' => 'utf8mb4', 'database.connections.mysql.charset' => 'utf8mb4',
@ -193,6 +191,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
$app->singleton(RootUrlBootstrapper::class); $app->singleton(RootUrlBootstrapper::class);
$app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class);
$app->singleton(FilesystemTenancyBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class);
$app->singleton(TenantConfigBootstrapper::class);
} }
protected function getPackageProviders($app) protected function getPackageProviders($app)
@ -236,11 +235,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
} }
public function randomString(int $length = 10)
{
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length);
}
public function assertArrayIsSubset($subset, $array, string $message = ''): void public function assertArrayIsSubset($subset, $array, string $message = ''): void
{ {
parent::assertTrue(array_intersect($subset, $array) == $subset, $message); parent::assertTrue(array_intersect($subset, $array) == $subset, $message);