1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-06-20 22:54:05 +00:00
Commit graph

520 commits

Author SHA1 Message Date
lukinovec
0d177c7e9b Use simple tenants table check during hardening
For a niche opt-in feature, checking if the tenants table is in the current schema is good enough for determining if the current DB is central -- the solution where we added getCurrentDatabaseName to each manager was overly complicated for this (though a bit more correct, not worth the added complexity).
2026-06-12 15:34:39 +02:00
lukinovec
34d19e94e2 Make hardening work with all db/schema managers
Previously, hardening only worked with databases, not with schemas. Also test that hardening works with all relevant db managers.
2026-06-10 13:21:41 +02:00
lukinovec
540e3635e2 Improve hardening
Make hardening work correctly even for named SQLite DBs, also make the related test test named SQLite DBs instead of just MySQL (the SQLite dataset fails when the DatabaseTenancyBootstrapper changes get reverted).
2026-06-09 09:56:02 +02:00
lukinovec
48b8aac42d Consider null parameters invalid
Parameters passed to validateParameter should always be non-null, and if they're null, an exception is thrown.
2026-06-09 08:15:07 +02:00
88156d175d
improve test name 2026-06-08 15:09:30 -07:00
3fb0176878
improve mysql charset/collation validation test 2026-06-08 14:59:40 -07:00
lukinovec
36782ebee7 Move mysql charset/collation validation assertions to a dedicated test 2026-06-08 11:51:35 +02:00
lukinovec
d28abdec89 Delete redundant db username config 2026-06-08 11:44:57 +02:00
lukinovec
cf7e086bf7 Clean up SQLiteDatabaseManager::$path in afterEach 2026-06-08 11:42:03 +02:00
lukinovec
49356a5513 Use more specific exception assertions 2026-06-08 11:37:06 +02:00
lukinovec
407197b190 Use datasets in hardening tests 2026-06-08 11:26:07 +02:00
9055b61a04
Merge branch 'master' into validate-sql-parameters 2026-06-07 14:17:28 -07:00
lukinovec
dfb0e1ad66
TenancyUrlGenerator: override toRoute(), refactor (#1439)
This PR adds the `toRoute()` method override to `TenancyUrlGenerator`.
`toRoute()` now attempts to find a tenant equivalent of the passed route
(= a route with the same name as the passed one, but with the tenant
prefix) and generates URL for the tenant route. This behavior can be
bypassed using the bypass parameter, like with the `route()` method
override `TenancyUrlGenerator` had until now.

The primary reason for adding this is that Livewire v4 no longer uses
the `route()` helper (which automatically prefixes the passed route name
because of the override in `TenancyUrlGenerator`) in
`Livewire::getUpdateUri()`. Now, it uses `toRoute()`
(544aa3dfb8 (diff-e7609f8b0a60bde5a85067803d4e2f08f235c7cee9225a51ea67a85ff9a1d694R52)),
which didn't automatically swap the route for its 'tenant.'-prefixed
equivalent in tenant context (until now). So for the Livewire
integration to work with path identification, we need to override
`toRoute()` as described.

The `temporarySignedRoute()` override got removed because
`temporarySignedRoute()` calls `route()` under the hood, there's no need
to specifically override `temporarySignedRoute()`.

> Note: Browsing old convos, it seems like the `temporarySignedRoute()`
override was needed to make Livewire file uploads work with path
identification, but it's not needed anymore. TenancyUrlGenerator had
some changes since then, and now, I can't see the _exact_ reason why we
needed the override (`temporarySignedRoute()` uses `route()` under the
hood, so the only thing that should really matter is overriding
`route()`/`toRoute()`). It was likely a leftover from some older
implementation.

The `route()` override got simplified. Since `route()` uses `toRoute()`
under the hood, the `route()` override only has to have the prefixing
logic. The rest is delegated to `toRoute()`.

> Note: Even though we override `toRoute()` now which `route()` uses for
generating the URLs, we still need to override `route()` for its
`$this->routes->getByName($name)` call to receive the prefixed name. For
example, if `route()` wasn't overridden, and we only had one route:
`tenant.foo` (no central `foo` route), and we'd call `route('foo')`,
we'd get an exception saying that route "foo" wasn't found, even if
automatic route name prefixing was enabled and `toRoute()` was
overridden. With the `route()` override, `route('foo')` acts as if we
passed 'tenant.foo' instead of 'foo'.

Comments in TenancyUrlGenerator and UrlGeneratorBootstrapper got updated
to be more accurate. All _intentionally_ affected methods are listed in
TenancyUrlGenerator's docblock.

---------

Co-authored-by: Samuel Stancl <samuel@archte.ch>
2026-06-06 14:52:37 -07:00
lukinovec
ad4c924d5c
[MINOR BC] Create pending tenants with pending_since, improve --with-pending (#1458)
> Minor breaking change: Pending tenants would previously go through the
creation pipeline as *not* pending and would only be marked as pending
after full creation. Now, pending tenants go through the creation
process with pending_since set from the start.

Pending tenants aren't getting their `pending_since` set until they're
created completely (e.g. their DB was created, migrated and seeded --
first, the tenant is created fully, and only after that, the tenant is
updated to have `pending_since`). This is a problem if someone wants to
e.g. add a job to the `DatabaseCreated` job pipeline that would check
`$this->tenant->pending()`. Since at the point of `DatabaseCreated`, the
tenant's `pending_since` isn't set yet, `$this->tenant->pending()`
returns `false`, even for tenants created using `createPending()`. So
instead of letting the pending tenant get fully created, and only after
that, setting its `pending_since` (using `update()`), we now set
`pending_since` in `create()`. `CreatingPendingTenant` is now dispatched
from the `static::creating` hook, and `PendingTenantCreated` is
dispatched from `static::created` for consistency.

Setting `pending_since` right in `create()` made the `MigrateDatabase`
and `SeedDatabase` jobs exclude the pending tenants during their
creation if the `tenancy.pending.include_in_queries` config was set to
`false` -- in that case, these jobs would never migrate or seed the
databases of pending tenants. So these jobs now pass `--with-pending` to
their underlying commands, with the value set in their `$includePending`
static property (`true` by default). This overrides the
`tenancy.pending.include_in_queries` config -- unless the
`$includePending` properties are set to `false`, these jobs will always
include pending tenants.

The `--with-pending` tenant command option originally worked just to
opt-in for including pending tenants in the command. Now,
`--with-pending` can accept values (`true`/`1` or `false`/`0`), so e.g.
- `tenants:run foo` with
`--with-pending`/`--with-pending=true`/`--with-pending=1` includes
pending tenants
- `tenants:run foo` with `--with-pending=false`/`--with-pending=0`
**excludes** pending tenants (also `--with-pending=foobar` -- invalid
input, considered `false`)

Passing `--with-pending` makes the command bypass the
`tenancy.pending.include_in_queries` config (so e.g. if
`tenancy.pending.include_in_queries` is set to `true`, and
`--with-pending=false` is passed to a command, the command will exclude
pending tenants). When `--with-pending` is not passed, the command will
include or exclude pending tenants based on the
`tenancy.pending.include_in_queries` config.

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Samuel Štancl <samuel@archte.ch>
2026-06-05 15:36:57 -07:00
lukinovec
c0fbf6dcbd
[MINOR BC] UserImpersonation: store auth guard in session, add $logout param to stopImpersonating() (#1437)
> Minor breaking change: `session('tenancy_impersonating')` doesn't work
anymore. Use `session('tenancy_impersonation_guard')` instead.

The 'tenancy_impersonating' session variable got replaced by
'tenancy_impersonation_guard'. `UserImpersonation::stopImpersonating()`
now calls `logout()` on the guard retrieved by
`session()->get('tenancy_impersonation_guard')` instead of calling
`logout()` on the _current_ auth guard. Now. if you create the
impersonation token with guard 'web', and call
`UserImpersonation::stopImpersonating()`, for example in a route that
has the `auth:sanctum` middleware (= the current guard in that route
would be `RequestGuard` which doesn't even have the `logout()` method --
not the guard for which the impersonation token was created), the method
will correctly log the user out of the 'web' guard using which he was
actually authenticated instead of the current guard of the visited route
(which doesn't have to be the same guard for which impersonation
started).

`UserImpersonation::stopImpersonating()` now also accepts the `$logout`
parameter, which is `true` by default. If `false` is passed, the method
just forgets `tenancy_impersonation_guard` from session without logging
out.

`UserImpersonation::stopImpersonating()` now throws an exception if
impersonation wasn't active at the point of calling the method.

---------

Co-authored-by: Samuel Stancl <samuel@archte.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-05 14:15:19 -07:00
lukinovec
ec06dcc52e
Correct DomainTenantResolver::isSubdomain() check (#1425)
Added a failing test for determining if a host is a subdomain, then
fixed `DomainTenantResolver::isSubdomain()` (similar fix as in #1423)
and a related assertion.

Previously, while having `tenancy.identification.central_domains` set to
e.g. `['site.com']`, the `isSubdomain()` check consider `tenantsite.com`
a subdomain because it ends with `site.com`. Now, instead of the
`endsWith()` check, the method checks if the passed domain is in the
configured central domains. If it is, it returns `false`. Otherwise,
loop through all the central domains and check if the passed domain
matches any of the central domains prefixed with a dot (e.g.
`tenant.site.com` would be considered a subdomain, `tenant.site.com`
wouldn't).

Because in InitializeTenancyByDomainOrSubdomain, if tenancy fails to
initialize using a subdomain (before this PR's changes, e.g.
`tenantsite.com` would be considered a subdomain, and `tenantsite` would
be used for initializing tenancy), it'll catch the exception and use the
whole domain for identification instead, this error will likely never be
noticed in real-world usage. So this PR corrects the subdomain detection
logic, but the real-world impact of that is negligible.

> Note: The subdomain error catching logic in domainOrSubdomain ID MW
was added in v4. If we applied this change in v3, it'd fix a real issue
where domainOrSubdomain ID MW would just fail at the subdomain
initialization, without attempting domain initialization after the
failure.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Samuel Štancl <samuel@archte.ch>
2026-05-11 14:26:06 +02:00
lukinovec
d9ae27425d Delete redundant cleanup 2026-05-05 10:06:41 +02:00
lukinovec
519c819e28 Delete user created in validation test 2026-05-05 08:30:18 +02:00
lukinovec
649c8027f4 Use unique DB names and passwords in test 2026-05-04 17:14:16 +02:00
lukinovec
099a666dbc Add valid password assertion 2026-05-04 15:38:42 +02:00
lukinovec
587f347b64 Restore default charset after assertion 2026-05-04 15:19:12 +02:00
lukinovec
bbd8f6fd98 Add parentheses to instanceof check 2026-05-04 14:37:01 +02:00
lukinovec
03318752b6 Specify charset and collation config in test 2026-05-04 13:40:42 +02:00
lukinovec
66ae88a325 Fix non-string parameter validation assertion 2026-05-04 13:26:01 +02:00
lukinovec
e59195eefe Improve coverage
Cover non-string parameter validation and in-memory DB name validation
2026-05-04 13:04:57 +02:00
lukinovec
bdbfbd4561 Remove extra variable 2026-05-04 12:32:25 +02:00
lukinovec
de913486e0 Specify exception message in assertions 2026-05-04 12:27:46 +02:00
lukinovec
338526d9fb Query for MySQL defaults instead of assuming them in charset test 2026-05-04 11:54:45 +02:00
lukinovec
405aaafb4e Handle MySQL charset and collation
Make createDatabase execute CREATE DATABASE without passing charset and collation so that if these parameters are null, the MySQL server's defaults will be used. Only add charset and collation to the statement if they're not null.
2026-05-04 11:15:51 +02:00
Thomas
23b18c93a0
Skip DB deletion when create_database=false, add ignoreFailures (#1394)
Database deletion is now skipped by default if the tenant has the
`create_database` internal attribute set to false, meaning it was likely
created without a database. This skip can be opted out of by changing a
static property.

It also adds an opt-in static property for ignoring any other failures
during database deletion, to allow continuing execution of the delete
pipeline.

---------

Co-authored-by: Samuel Štancl <samuel@archte.ch>
2026-05-01 21:57:19 +02:00
lukinovec
26c161a940 Add regression test for makeConnectionConfig not working correctly with custom $path
In makeConnectionConfig, changing the $this->getPath($databaseName) line back to `$baseConfig['database'] = database_path($databaseName);` will make the added test fail.
2026-05-01 15:16:54 +02:00
lukinovec
7f93f4460a Test that the SQLite DB manager recognizes in-memory DBs 2026-05-01 14:35:18 +02:00
lukinovec
7363318f6e Make in-memory DB detection more strict
In-memory DBs have to start with "file:_tenancy_inmemory_". This prevents path traversal.
2026-05-01 13:09:37 +02:00
lukinovec
b1f0d0a43c
Get central DB from config in harden test
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-01 12:34:28 +02:00
lukinovec
2ae1f79d50 Cover empty string parameters 2026-05-01 12:32:03 +02:00
lukinovec
0ce3d863ce DATABASE_URL test: set config for both datasets 2026-05-01 12:11:00 +02:00
lukinovec
52f6857302 If harden throws an exception, revert connection back to central 2026-05-01 12:08:02 +02:00
lukinovec
f5f5f1d4aa Fix DB bootstrapper test
"database tenancy bootstrapper throws an exception if DATABASE_URL is set" was failing with the null $databaseUrl because the tenant DB was never created. This test was ignored during test runs because the test file lacked the 'Test' suffix.
2026-05-01 11:53:27 +02:00
lukinovec
fbd1e02564 Correct DatabaseTenancyBootstrapper test filename
DatabaseTenancyBootstrapper is ignored by ./t, it should be suffixed with 'Test'.
2026-05-01 11:50:01 +02:00
lukinovec
665404e7fa Add DatabaseTenancyBootstrapper::$harden
Since It's possible to update tenant's db_name to the central DB or the DB of another tenant. Setting $harden to true prevents tenants from connecting to the wrong databases.
2026-05-01 11:44:56 +02:00
lukinovec
76c324d758 Add validateFilename()
Use validateFilename instead of validateParameter in SQLiteDatabaseManager. Directories are no longer considered valid SQLite database names.
2026-05-01 09:03:50 +02:00
lukinovec
bacbf934e1 Improve validation exception message 2026-04-30 14:52:53 +02:00
lukinovec
50ea524ad2 Simplify test, improve comments 2026-04-30 11:16:39 +02:00
lukinovec
4bdb877ca4 Cover null parameter skipping
Also cover that in-memory db names aren't validated in databaseExists
2026-04-30 10:45:29 +02:00
lukinovec
322257f456 Validate SQLite filename in databaseExists
Add validation so that a malicious tenant DB name can't be used to detect if a file exists.
2026-04-30 09:49:03 +02:00
lukinovec
4a3e6bae00 Test invalid passwords, improve test name and comments 2026-04-29 17:35:11 +02:00
lukinovec
db03997339 Validate SQLite DB names in create/deleteDatabase()
Also stop skipping the validation test for sqlite.
2026-04-29 17:35:11 +02:00
lukinovec
5adbc14a7e Test SQL parameter validation
WIP: password validation, SQLite (check if validation is enough for valid FS paths), revisit the test
2026-04-29 14:16:00 +02:00
lukinovec
984911946a
Change tenant storage listeners into jobs (#1446)
The `CreateTenantStorage` and `DeleteTenantStorage` listeners were used
alongside JobPipelines. When the `TenantCreated` JobPipeline had
`shouldBeQueued(true)` and the `Listeners\CreateTenantStorage` was
uncommented, the listener would throw an exception
(`Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException
Database tenantX.sqlite does not exist.`) because at the time of
executing the listener, the tenant DB wasn't created yet.

The same issue could likely also occur in the `DeleteTenantStorage`
listener as it uses `tenancy()->run()` to resolve the tenant's storage
path which wouldn't work if the tenant's database (or other resources)
was already deleted, making initialization impossible.

This PR changes `DeleteTenantStorage` into a job and puts it (commented)
into the job pipeline, so that it can be queued with the rest of the
jobs. It also removes `CreateTenantStorage` because it should be
redundant with the FilesystemTenancyBootstrapper creating the same paths
automatically when storage path is suffixed.

The old classes are kept but deprecated for backwards compatibility.

We've also added some edge case hardening to `DeleteTenantStorage` to
make sure it never deletes the central storage path directory, which
previously could in theory occur due to a misconfiguration if a user
enabled this job/listener but disabled storage path suffixing.

Co-authored-by: Samuel Štancl <samuel@archte.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-22 16:45:54 +02:00
lukinovec
ab2a4d8438
Fix chaining withoutPending() with where() (#1457)
At the moment, `where()` cannot be used correctly while using
`withoutPending()`. For example, if we have a single non-pending tenant
in our DB (with ID 'foo'), queries like
`Tenant::withoutPending()->where('id', 'nonexistent')->first()`will
incorrectly return the non-pending tenant ('foo').

This is because `withoutPending()` does
`$builder->whereNull('data->pending_since')->orWhereNull('data')`. These
two aren't grouped, so `withoutPending()->where('id', 'nonexistent')`
basically translates to "WHERE data->pending_since IS NULL **OR (data IS
NULL AND id = 'nonexistent')**". So the query will include all tenants
whose `pending_since` is null (= all non-pending tenants).

Grouping `->whereNull('data->pending_since')->orWhereNull('data')` in a
closure passed to a separate `where()` fixes this issue.
2026-04-22 14:32:53 +02:00