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:
commit
41e6e7c9c8
97 changed files with 1723 additions and 485 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
30
Dockerfile
30
Dockerfile
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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#'
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
* [
|
* [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
* [
|
* [
|
||||||
|
|
|
||||||
54
src/Bootstrappers/TenantConfigBootstrapper.php
Normal file
54
src/Bootstrappers/TenantConfigBootstrapper.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/Commands/PurgeImpersonationTokens.php
Normal file
38
src/Commands/PurgeImpersonationTokens.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}'");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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')) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/ResourceSyncing/Events/SyncedResourceDeleted.php
Normal file
18
src/ResourceSyncing/Events/SyncedResourceDeleted.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
40
src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php
Normal file
40
src/ResourceSyncing/Listeners/DeleteAllTenantMappings.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/ResourceSyncing/Listeners/DeleteResourceMapping.php
Normal file
60
src/ResourceSyncing/Listeners/DeleteResourceMapping.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/ResourceSyncing/PivotWithCentralResource.php
Normal file
11
src/ResourceSyncing/PivotWithCentralResource.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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', []);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
20
src/UniqueIdentifierGenerators/UUIDv7Generator.php
Normal file
20
src/UniqueIdentifierGenerators/UUIDv7Generator.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\UniqueIdentifierGenerators;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a UUIDv7 for the tenant key.
|
||||||
|
*/
|
||||||
|
class UUIDv7Generator implements UniqueIdentifierGenerator
|
||||||
|
{
|
||||||
|
public static function generate(Model $model): string|int
|
||||||
|
{
|
||||||
|
return Str::uuid7()->toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
103
static_properties.nu
Executable 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
2
t
|
|
@ -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
2
test
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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' => [
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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', [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) : [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', [
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
|
||||||
]) : [],
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue