mirror of
https://github.com/archtechx/tenancy.git
synced 2026-09-20 19:24:03 +00:00
> The cache part of this is specific to `file`-driver stores that are
listed in `tenancy.cache.stores`, while `tenancy.filesystem.scope_cache`
is set to `true`. The session part applies to the `file` session driver,
while `tenancy.filesystem.scope_sessions` is set to `true`.
>
> Ran into this while checking whether we could drop the separate
'parallel' cache store from our boilerplate's testing setup and instead
just give the 'file' store a per-process path
(`framework/cache/data_<parallel testing token>`), so each test process
gets its own cache directory. Turns out `scopeCache()` discards
configured paths entirely, so that has no effect (see below).
Using a different directory for the `file` cache store by setting
`cache.stores.file.path` (either using `config([...])`, or directly in
`config/cache.php` -- doesn't matter) has no effect --
`FilesystemTenancyBootstrapper::scopeCache()` ignores `path`/`lock_path`
entirely and rewrites both to a hardcoded
`<storage>/framework/cache/data` path on every
`tenancy()->initialize()`/`tenancy()->end()`:
```php
// In `FilesystemTenancyBootstrapper::scopeCache()` (called both in `bootstrap()` and in `revert()`)
foreach ($stores as $name) {
$path = $storagePath . '/framework/cache/data';
$this->app['config']["cache.stores.{$name}.path"] = $path;
$this->app['config']["cache.stores.{$name}.lock_path"] = $path;
...
}
```
Specific issues with hardcoding the path like this:
- a store with a configured (non-default) path gets scoped to use the
default one (what I described above)
- `lock_path` is always overwritten with `path`, so a store with a
separate lock directory loses that separation
- `revert()` runs the same code, so it doesn't restore what the store
was configured with before tenancy initialized -- it just re-applies the
same hardcoded default. Central cache ends up using the wrong path after
ending tenancy.
**`scopeSessions()` has the same bug.** It never reads `session.files`,
it hardcodes `<storage>/framework/sessions` on both bootstrap and
revert. So a configured session path gets discarded when tenancy
initializes, and it doesn't get reverted back to what it was when
tenancy ends. For example, with `session.files` set to
`/tmp/foo-sessions`:
```
In tenant context: session.files = .../storage/tenant<key>/framework/sessions
After ending tenancy: session.files = .../storage/framework/sessions
```
So after ending tenancy, sessions don't go back to the configured
`/tmp/foo-sessions`. They use the hardcoded
`<storage>/framework/sessions` path, which was never configured
anywhere.
### The fix
In `bootstrap()`, `scopeCache()` captures the original configured paths
and scopes those instead of using a hardcoded default.
- If the configured path is under the central storage path, that central
part gets swapped for the tenant's storage path, keeping everything
after it the same (e.g. `storage/framework/cache/data` becomes
`storage/tenant1/framework/cache/data`).
- If the path isn't `storage_path()`-based, there's nothing to swap, so
the tenant's suffix just gets appended to the end of the path instead.
On `revert()`, `scopeCache(false)` puts the captured paths back into
`cache.stores.{$name}.path`/`lock_path` and into the resolved store
instance, so central cache uses the path it was configured with again.
> The paths are captured just once, during `scopeCache()` at
`bootstrap()`. If `bootstrap()` fails after `scopeCache()` (e.g. when
`scopeSessions()` can't create its directory), `revert()` never runs and
the config is left with the scoped paths, so capturing a second time
would lose the central ones. Also, `revert()` iterates the stores whose
paths were captured during bootstrap rather than
`config('tenancy.cache.stores')`. Removing a store from that config in
tenant context would otherwise make `revert()` skip it and leave it
stuck with a tenant-scoped path, and adding one would make
`tenancy()->end()` throw because its path was never captured (see the
'scopeCache ignores changes to tenancy.cache.stores made in tenant
context' test). These are edge cases most users wouldn't notice, but
still, worth mentioning.
`lock_path` stays `null` when a store doesn't configure it, rather than
us making it default to the scoped path -- `FileStore` already falls
back to `path` for locks in that case, so we can just respect the
store's original config.
`scopeSessions()` does the same for `session.files`. It captures the
configured path during `bootstrap()` (once, for the same reason as
above), scopes it, and puts it back on `revert()` (the default
`storage/framework/sessions` still ends up as
`storage/tenant1/framework/sessions`, so nothing changes for the default
config).
Also added tests that cover each of the issues above (+ a test for
handling paths that aren't `storage_path()`-based, and one for a custom
`session.files` path).
**POSSIBLE MINOR BC:** Someone with `'path' => '/var/cache/foobar'`
currently gets tenant cache in `storage/tenant1/framework/cache/data`.
After this fix, they get `/var/cache/foobar/tenant1`, so whatever is
already cached in the old directory is orphaned. The same applies to a
non-default `storage_path()`-based path, e.g. `'path' =>
storage_path('framework/cache/data_' . env('TEST_TOKEN', 'default'))`.
The config is now respected while scoping. The same goes for sessions --
with a non-default `session.files`, tenant sessions move from
`storage/tenant1/framework/sessions` to the configured path scoped for
the tenant, so the sessions in the old directory are orphaned.
## Note about directory separators
While implementing a method that centralizes scoping a path to the
tenant (`tenantScopedPath()`), we looked into which code should use
`DIRECTORY_SEPARATOR` instead of plain `/` (for Windows compatibility,
overall correctness and consistency).
In short, the separators only matter in code that compares paths --
there, both sides of the check have to use the same separators (it
doesn't matter whether that's `DIRECTORY_SEPARATOR` or `/`). Strings
that only get passed to the filesystem are fine with plain `/` -- the
filesystem handles these just fine (for example, Laravel uses
`storage_path('framework/cache/data')` as the default `file` cache
store's path, and that works just fine on Windows).
A thing related to this are the `rtrim()` calls. In `diskRoot()`'s "disk
present in `tenancy.filesystem.disks`, but not in
`tenancy.filesystem.root_override`" code branch, `rtrim()` only trimmed
the `/` separator. So if a Windows user used a local disk like that, and
configured that disk's path to use a trailing separator, the method
would set the disk root to a path like `C:\app\uploads\/tenant1`. In
practice, this shouldn't be an issue, but trimming both `/` and `\`
prevents that code from setting the root to a weird path like that (so a
very low impact change).
The `tenantStoragePath()` method got the same treatment as `diskRoot()`
mentioned above: the original storage path _could_ be configured with a
trailing slash. Again, this was a non-issue, but only because the
method's output didn't get compared to other strings in a way where this
_could_ be an issue. The `rtrim` there is a _slight_ improvement, but
primarily, this got changed for consistency with the change in
`diskRoot()`.
---------
Co-authored-by: Samuel Stancl <samuel@archte.ch>
314 lines
14 KiB
PHP
314 lines
14 KiB
PHP
<?php
|
|
|
|
use Illuminate\Session\Middleware\StartSession;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Stancl\JobPipeline\JobPipeline;
|
|
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
|
use Stancl\Tenancy\Bootstrappers\DatabaseSessionBootstrapper;
|
|
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
|
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
|
use Stancl\Tenancy\Events\TenancyEnded;
|
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
|
use Stancl\Tenancy\Events\TenantCreated;
|
|
use Stancl\Tenancy\Jobs\CreateDatabase;
|
|
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
|
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
|
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
|
|
|
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
|
|
|
|
beforeEach(function () {
|
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
|
|
|
// Middleware priority logic
|
|
$tenancyMiddleware = array_merge([PreventAccessFromUnwantedDomains::class], config('tenancy.identification.middleware'));
|
|
foreach (array_reverse($tenancyMiddleware) as $middleware) {
|
|
app()->make(\Illuminate\Contracts\Http\Kernel::class)->prependToMiddlewarePriority($middleware);
|
|
}
|
|
});
|
|
|
|
test('file sessions are separated', function (bool $scopeSessions) {
|
|
config([
|
|
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
|
'tenancy.filesystem.suffix_storage_path' => false,
|
|
'tenancy.filesystem.scope_sessions' => $scopeSessions,
|
|
'session.driver' => 'file',
|
|
]);
|
|
|
|
$sessionPath = fn () => invade(app('session')->driver()->getHandler())->path;
|
|
|
|
expect($sessionPath())->toBe(storage_path('framework/sessions'));
|
|
File::cleanDirectory(storage_path("framework/sessions")); // clean up the sessions dir from past test runs
|
|
|
|
$tenant = Tenant::create();
|
|
$tenant->enter();
|
|
|
|
if ($scopeSessions) {
|
|
expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions'));
|
|
expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue();
|
|
} else {
|
|
expect($sessionPath())->toBe(storage_path('framework/sessions'));
|
|
}
|
|
|
|
$tenant->leave();
|
|
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
|
|
if ($scopeSessions) {
|
|
expect(File::files(storage_path("tenant{$tenant->id}/framework/sessions")))->toHaveCount(0);
|
|
} else {
|
|
expect(File::exists(storage_path("tenant{$tenant->id}/framework/sessions")))->toBeFalse();
|
|
}
|
|
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
if ($scopeSessions) {
|
|
expect(File::files(storage_path("tenant{$tenant->id}/framework/sessions")))->toHaveCount(1);
|
|
expect(File::files(storage_path("framework/sessions")))->toHaveCount(0);
|
|
} else {
|
|
expect(File::exists(storage_path("tenant{$tenant->id}/framework/sessions")))->toBeFalse();
|
|
expect(File::files(storage_path("framework/sessions")))->toHaveCount(1);
|
|
}
|
|
})->with([true, false]);
|
|
|
|
test('file sessions are separated when a custom session path is configured', function () {
|
|
$centralStoragePath = storage_path();
|
|
$configuredSessionPath = "{$centralStoragePath}/framework/foo_sessions";
|
|
|
|
config([
|
|
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
|
|
'session.driver' => 'file',
|
|
'session.files' => $configuredSessionPath,
|
|
]);
|
|
|
|
$sessionPath = fn () => invade(app('session')->driver()->getHandler())->path;
|
|
|
|
expect($sessionPath())->toBe($configuredSessionPath);
|
|
|
|
File::cleanDirectory($configuredSessionPath); // clean up the configured sessions dir from past test runs
|
|
|
|
$tenant = Tenant::create();
|
|
$tenantSessionPath = "{$centralStoragePath}/tenant{$tenant->id}/framework/foo_sessions";
|
|
|
|
$tenant->enter();
|
|
|
|
// The configured path gets scoped to the tenant
|
|
expect($sessionPath())->toBe($tenantSessionPath);
|
|
// Initializing tenancy creates the tenant session dir
|
|
expect(is_dir($tenantSessionPath))->toBeTrue();
|
|
|
|
$tenant->leave();
|
|
|
|
// Session path reverts back to the original configured path
|
|
expect($sessionPath())->toBe($configuredSessionPath);
|
|
|
|
// StartSession saves the session at the end of the request, so each request below creates a session file
|
|
Route::middleware([StartSession::class, InitializeTenancyByPath::class])->get('/{tenant}/foo', fn () => 'bar');
|
|
Route::middleware(StartSession::class)->get('/central', fn () => 'bar');
|
|
|
|
expect(File::files($tenantSessionPath))->toHaveCount(0);
|
|
|
|
// Visiting a tenant route should create the session file at the configured path scoped for the tenant
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(File::files($tenantSessionPath))->toHaveCount(1);
|
|
expect(File::files($configuredSessionPath))->toHaveCount(0);
|
|
|
|
// End tenancy to test the revert behavior (= the central session file gets created at the original configured path)
|
|
tenancy()->end();
|
|
|
|
pest()->get('/central');
|
|
|
|
expect(File::files($configuredSessionPath))->toHaveCount(1);
|
|
});
|
|
|
|
test('redis sessions are separated using the redis bootstrapper', function (bool $bootstrappedEnabled) {
|
|
config([
|
|
'tenancy.bootstrappers' => $bootstrappedEnabled ? [RedisTenancyBootstrapper::class] : [],
|
|
'session.driver' => 'redis',
|
|
]);
|
|
|
|
$redisClient = app('session')->driver()->getHandler()->getCache()->getStore()->connection()->client();
|
|
expect($redisClient->getOption($redisClient::OPT_PREFIX))->toBe('foo'); // default prefix configured in TestCase
|
|
|
|
expect(Redis::keys('*'))->toHaveCount(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled);
|
|
|
|
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
|
return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_"));
|
|
}))->toHaveCount($bootstrappedEnabled ? 1 : 0);
|
|
})->with([true, false]);
|
|
|
|
test('redis sessions are separated using the cache bootstrapper', function (bool $scopeSessions) {
|
|
config([
|
|
'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class],
|
|
'session.driver' => 'redis',
|
|
'tenancy.cache.stores' => [], // will be implicitly filled
|
|
'tenancy.cache.scope_sessions' => $scopeSessions,
|
|
]);
|
|
|
|
expect(Redis::keys('*'))->toHaveCount(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
|
|
|
tenancy()->end();
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
|
|
|
expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) {
|
|
return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}"));
|
|
}))->toHaveCount($scopeSessions ? 1 : 0);
|
|
})->with([true, false]);
|
|
|
|
test('memcached sessions are separated using the cache bootstrapper', function (bool $scopeSessions) {
|
|
config([
|
|
'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class],
|
|
'session.driver' => 'memcached',
|
|
'tenancy.cache.stores' => [], // will be implicitly filled
|
|
'tenancy.cache.scope_sessions' => $scopeSessions,
|
|
]);
|
|
|
|
$allMemcachedKeys = fn () => cache()->store('memcached')->getStore()->getMemcached()->getAllKeys();
|
|
|
|
if (count($allMemcachedKeys()) !== 0) {
|
|
sleep(1);
|
|
}
|
|
|
|
expect($allMemcachedKeys())->toHaveCount(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
|
|
|
tenancy()->end();
|
|
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
|
|
expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) {
|
|
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
|
}))->toHaveCount($scopeSessions ? 1 : 0);
|
|
|
|
Artisan::call('cache:clear memcached');
|
|
})->with([true, false]);
|
|
|
|
test('dynamodb sessions are separated using the cache bootstrapper', function (bool $scopeSessions) {
|
|
config([
|
|
'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class],
|
|
'session.driver' => 'dynamodb',
|
|
'tenancy.cache.stores' => [], // will be implicitly filled
|
|
'tenancy.cache.scope_sessions' => $scopeSessions,
|
|
]);
|
|
|
|
$allDynamodbKeys = fn () => array_map(fn ($res) => $res['key']['S'], cache()->store('dynamodb')->getStore()->getClient()->scan(['TableName' => 'cache'])['Items']);
|
|
|
|
expect($allDynamodbKeys())->toHaveCount(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
|
|
|
tenancy()->end();
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
|
|
|
expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) {
|
|
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
|
}))->toHaveCount($scopeSessions ? 1 : 0);
|
|
})->with([true, false]);
|
|
|
|
test('apc sessions are separated using the cache bootstrapper', function (bool $scopeSessions) {
|
|
config([
|
|
'tenancy.bootstrappers' => [CacheTenancyBootstrapper::class],
|
|
'session.driver' => 'apc',
|
|
'tenancy.cache.stores' => [], // will be implicitly filled
|
|
'tenancy.cache.scope_sessions' => $scopeSessions,
|
|
]);
|
|
|
|
$allApcuKeys = fn () => array_column(apcu_cache_info()['cache_list'], 'info');
|
|
expect($allApcuKeys())->toHaveCount(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions);
|
|
|
|
tenancy()->end();
|
|
expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey());
|
|
|
|
expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) {
|
|
return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}"));
|
|
}))->toHaveCount($scopeSessions ? 1 : 0);
|
|
})->with([true, false]);
|
|
|
|
test('database sessions are separated regardless of whether the session bootstrapper is enabled', function (bool $sessionBootstrappedEnabled, bool $connectionSet) {
|
|
config([
|
|
'tenancy.bootstrappers' => $sessionBootstrappedEnabled
|
|
? [DatabaseTenancyBootstrapper::class, DatabaseSessionBootstrapper::class]
|
|
: [DatabaseTenancyBootstrapper::class],
|
|
'session.driver' => 'database',
|
|
'session.connection' => $connectionSet ? 'central' : null,
|
|
'tenancy.migration_parameters.--schema-path' => 'tests/Etc/session_migrations',
|
|
]);
|
|
|
|
Event::listen(
|
|
TenantCreated::class,
|
|
JobPipeline::make([CreateDatabase::class, MigrateDatabase::class])->send(function (TenantCreated $event) {
|
|
return $event->tenant;
|
|
})->toListener()
|
|
);
|
|
|
|
pest()->artisan('migrate', [
|
|
'--path' => __DIR__ . '/Etc/session_migrations',
|
|
'--realpath' => true,
|
|
])->assertExitCode(0);
|
|
|
|
expect(DB::connection('central')->table('sessions')->count())->toBe(0);
|
|
|
|
$tenant = Tenant::create();
|
|
Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar');
|
|
pest()->get("/{$tenant->id}/foo");
|
|
|
|
expect(invade(app('session')->driver()->getHandler())->connection->getName())->toBe('tenant');
|
|
|
|
expect(DB::connection('tenant')->table('sessions')->count())->toBe(1);
|
|
expect(DB::connection('central')->table('sessions')->count())->toBe(0);
|
|
})->with([
|
|
[true, true],
|
|
[true, false],
|
|
// [false, true], // when the connection IS set, the session bootstrapper becomes necessary
|
|
[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;
|
|
}
|
|
}
|