Multi-tenancy

Multi-tenancy

One deployment, one database, many tenants. A user is global and signs in once; what they may do depends on which tenant the request resolved to.

This describes what is in the code. Where a decision came out differently from the original design, the code is what is written down here.

Model

  • One database, one app. Marten stamps a tenant id on every tenanted document and filters every query by the session's tenant, so isolation is a property of the session rather than a WHERE clause somebody has to remember.
  • One global identity. A User exists once across all tenants (Models/User.cs).
  • Membership carries per-tenant roles. Membership links a user to a tenant slug with the roles they hold there (Models/Membership.cs). Note it stores TenantSlug, a string, not a tenant id.
  • A default tenant. Tenant.DefaultSlug is "default", and TenantSessionFactory opens a tenant-less session for it, so a single-tenant deployment keeps working with no tenant rows and no migration.

What is global and what is tenant-scoped

options.Policies.AllDocumentsAreMultiTenanted() makes everything tenanted by default (Extensions/ServiceCollectionExtensions.cs), and options.Events.TenancyStyle = Conjoined does the same for the event store. Documents then opt out one by one.

Global (SingleTenanted) Tenant-scoped (the default)
User Content, ContentTypeDefinition
Tenant (the registry itself) StoredFile, FileBlob
Membership (necessarily cross-tenant) Account, JournalEntry, NumberSequence
Role, stored once with its tenant kept as data workflows and their events
RefreshToken, RevokedToken, OtpCode, MfaSecret, ApiKey
Device
AuditEvent

Two of those are worth calling out because the obvious guess is the other way round.

Roles belong to a tenant, or to the platform. A role is stored once for the deployment, but from 5.0 it carries its owner: TenantSlug for a tenant's own role, whose permissions name that tenant's content types, or IsPlatform for a role every tenant shares. The platform roles are the ones seeded for the deployment: SuperAdmin, Admin, User, the demo HR role, and a role a module seeds under its fixed id. Which roles a person holds in a tenant is still what Membership.RoleIds carries.

  • GET /api/roles answers with the caller's tenant's roles and the platform roles. A caller holding manage_platform_roles sees every tenant's.
  • GET, PUT and DELETE /api/roles/{id} answer 404 for another tenant's role, so a tenant cannot learn the ids of another's. A platform role can be read from any tenant; changing or deleting it needs manage_platform_roles, and without it the answer is 403.
  • POST /api/roles makes a role of the caller's tenant. isPlatform: true makes a platform role and needs manage_platform_roles. A name is unique within the tenant, and among platform roles; a second one is a 409.
  • manage_roles reaches the caller's tenant only. Without manage_platform_roles, a role write cannot add a capability that reaches past the tenant (*, manage_roles, manage_platform_roles, manage_tenants, manage_users, manage_email_settings, view_hidden), so a tenant's role editor cannot widen their own role into a platform one. Only SuperAdmin holds manage_platform_roles by default.
  • The member routes offer and accept the tenant's roles and the platform roles. Another tenant's role is answered as one that does not exist. The same holds for a tenant's sign-up role and MFA roles.
  • A membership that names another tenant's role, which no write path allows, gets nothing from it when roles are resolved. Nor does a tenant's role held as a global role, outside that tenant.
  • POST /api/users/{userId}/roles writes a global role, which reaches every tenant, so it takes only a platform role; a tenant's role is a 400.

migrations/5.0.0/role-tenancy.sql gives every role stored before 5.0 its owner; its header has the rule. A role held in several tenants is copied into each, so editing it in one no longer changes it in the others. A role an account holds globally becomes a platform role, so it keeps granting in every tenant and only manage_platform_roles changes it.

Auth artifacts are global. A refresh token, an OTP code, an MFA secret, an API key and a trusted device all belong to the global identity, not to the tenant whose subdomain happened to be in the URL when they were created. A device trusted once stays trusted; a revoked token is revoked everywhere.

Changing Events.TenancyStyle on an existing store is not a live migration. The comment at the configuration site says so, and it is the reason this is settled rather than adjustable.

Tenant resolution

TenantResolutionMiddleware resolves, in order:

  1. the X-Tenant header,
  2. a registered custom domain (Tenant.Domains, looked up through ITenantDomainSource),
  3. the host's leading subdomain, ignoring the infra labels www, app, api and admin,
  4. the default tenant.

The header is accepted from any caller, deliberately. That is how path-based routing works: the front end sets X-Tenant from the URL handle. Naming a tenant is not the same as reaching its data, because an authenticated request still has to survive TenantAccessMiddleware, and anonymous requests only ever reach content a tenant published. Forging the Host header selects exactly the same set of tenants by a longer route, which is why #147 closed as not-an-escalation.

Domains are written with the tenant, POST /api/tenants and PUT /api/tenants/{handle}, and each write clears the cached map, so a change routes on the next request. A domain belongs to one tenant; a second claim is a 409. GET /api/tenants/by-host/{host} answers which active tenant a host belongs to, anonymously and with the handle only, for a renderer serving several sites from one process. It resolves the host the same way requests are routed: a registered domain first, then the leading subdomain, so acme.example.com answers acme when an active tenant has that handle and no domain row claims the host. An unknown or inactive handle is a 404 either way.

The same map can drive CORS and TLS, so adding a client's domain needs no config edit or restart. CORS:AllowTenantDomains (off by default) allows a browser on https:// plus a registered domain, and GET /api/tenants/tls-ask?domain={host} answers a reverse proxy's on-demand TLS check. Both are in deploy-in-production.md.

RefuseUnknownHosts turns a host that looks like a custom domain but matches nothing into a 404, rather than quietly serving the default tenant. It is opt-in, because on a single-tenant deployment every host is legitimately unrecognised.

Steps 3 and 4 serve whatever they resolve to, registered or not, unless Tenancy:Mode is Multi. The next section is that setting.

A tenant that is not Active

A tenant has a Status: Active, Suspended or Archived, set with PUT /api/tenants/{handle} ({ "name": "...", "status": "Suspended" }). Suspended and Archived are refused the same way; Archived says the data is kept and nothing more. isActive is still accepted and returned: true is Active, false is Suspended, and false leaves an Archived tenant Archived. Sending both with different meanings is a 400. Leaving both out makes the tenant Active, as leaving isActive out always did. That holds on every PUT, unlike signUp and signIn, which are left as they are when left out: a client that saves only a suspended tenant's policy reactivates it unless it sends the current status too.

The status is read once, at resolution, in every mode. A request that resolves to such a tenant, by header, subdomain or one of its domains, gets:

  • 404 on delivery (/api/public/*), whoever asks;
  • 403 with the body This tenant is suspended. (or archived.) on any other route, when the caller's token names that tenant;
  • 404 on any other route for everyone else, with no body in Single and the body below in Multi, so a caller who is not the tenant's own learns nothing a slug nobody registered would not tell them.

The routes listed under Multi as answering without a tenant are not refused here: sign in refuses such a tenant itself, and the registry routes answer 404 for it. A tenant's domains keep resolving to it while it is not Active, so a request on one is refused rather than served the default tenant's content, but CORS and the TLS check treat the domain as nobody's.

Scheduled work waits. The scheduled content sweep and the collection sync do not visit the tenant, so nothing it scheduled fires while it is off. When it is Active again, the next sweep applies what fell due, with one rule: a publish whose unpublish time has also passed is missed. The entry goes straight to Archived, where the two would have left it, without being published, and both times are cleared, so no Published workflow runs for an entry that is already over. The same rule holds for an entry whose whole window passed while the service was down.

migrations/5.0.0/tenant-status.sql writes Status onto tenant documents stored before it existed. The API does not need it: a document with only IsActive reads back with the right status. Its rollback, rollback-tenant-status.sql, changes nothing, since IsActive is kept as a copy of Status for a release that reads only that.

Tenancy mode

Tenancy:Mode says what kind of deployment this is. It has two values. A value that is neither stops the host at startup with a message naming the setting, because the failure worth preventing is an operator who mistyped Multi and runs a deployment that serves what they believe it refuses. Change it by changing configuration and restarting.

Single, the default

Single is what every deployment did before the setting existed, and what you get by leaving it unset. Nothing is refused:

  • a request that names no tenant is served from the default partition;
  • a slug with no Tenant document is served as a partition of its own, and a token is issued for it without a membership check;
  • registered tenants work as described on this page, with membership checked.

The name describes the deployment those two exemptions exist for (one site, nobody ever creates a tenant). It does not stop a Single deployment from registering tenants, and X-Tenant is honoured exactly as before.

Multi

{ "Tenancy": { "Mode": "Multi" } }

Opt-in. Every request and every token belongs to a registered, active tenant.

Resolution refuses. A request gets 404 with the plain text body This deployment serves registered tenants only. when it resolves to:

  • the default partition (no header, no registered domain and no subdomain, or X-Tenant: default);
  • a slug with no Tenant document;
  • a tenant whose Status is not Active, for anyone who is not signed in to it (see above).

The three get the same status and the same body, so the answer does not say which slugs were ever registered. It is given by TenantResolutionMiddleware, before authentication and before any endpoint. It runs before the CORS middleware, so a browser calling from another origin sees a failed request and not the 404.

These routes answer without a tenant, and nothing else does. The list is in Infrastructure/Multitenancy/TenantlessRoutes.cs, so a new route is refused until it is added. Apart from /api/auth/*, each is allowed for GET and HEAD only:

Route Why
/health, /health/live, /health/ready, /health/build a probe names no tenant
/metrics a scraper names no tenant
GET /api/meta a console reads the contract version before it knows a tenant
GET /api/tenants/by-host/{host} how a renderer learns the tenant
GET /api/tenants/{handle}/public how a sign-in page learns the tenant. It reads the registry on the default slug, then that tenant's published site entry
GET /api/tenants/tls-ask how a reverse proxy asks whether to get a certificate for a host
/api/auth/* identity is stored once for the deployment, and a provider redirects a social sign-in to one fixed address

They run on the default slug whatever the request named, so a slug nobody registered never reaches a session or an audit row through them. The public profile route opens one more session, for the tenant the registry returned, and only after finding it registered and active. A CORS preflight (OPTIONS with Origin and Access-Control-Request-Method) is let through as well, since a browser sends it without X-Tenant; the CORS middleware answers it and no endpoint runs.

One consequence for the audit log. A sign-in or refresh sent with no tenant, or under an unregistered or inactive one, is recorded with tenant default, since that is the slug the request runs on. GET /api/audit shows a tenant administrator their own tenant's rows, so they do not see those attempts, including failed sign-ins against their own members. A SuperAdmin does, with GET /api/audit?tenant=default. A sign-in that names the tenant is recorded under it as before.

Left off on purpose: /api/me/* and /api/tenants (send the tenant you are signed in to), /swagger (the delivery paths in the document are generated per tenant), the optional health dashboard, and every module route, including the POST /api/webhooks/resend receiver. Reach those on a tenant's host or with X-Tenant.

No token for anything but a tenant. TokenIssuer refuses the default partition and any slug with no active Tenant document, and every path that issues an access token goes through it: password sign-in, OTP, MFA, refresh, POST /api/me/switch and the ExternalAuth providers. Reaching sign-in without a tenant is allowed; getting a token from it is not, and password sign-in answers with the same 401 a wrong password gets. POST /api/me/switch answers 400 for such a target, as it does for a tenant the caller does not belong to.

A token issued before the switch stops working. An access token lives fifteen minutes, so one minted for the default partition or an unregistered slug just before Multi was turned on, or for a tenant just before it was switched off, would otherwise still be accepted on the routes that do not compare the token's tenant with the request's (/api/me/*, /api/auth/*, routes ending /public). In Multi, TenantAccessMiddleware answers 403 on every route for a token whose own tenant claim is not a registered, active tenant. It reads the cached list of active tenants, so it follows that cache (below). A token with no tenant claim at all, which only tokens from before the claim existed are, is not covered and passes as it does in Single.

API keys. A key carries its own tenant. In Multi a key scoped to the default partition or to a slug with no active tenant is refused with 401. Resolution runs before the key is read, so a request with a key still has to name a registered, active tenant by host or X-Tenant. The key's own tenant is the one the request then runs in, as before.

Platform administration. A SuperAdmin manages tenants from inside a registered tenant they belong to: sign in there, and /api/tenants lists, creates and updates every tenant. Creating a tenant makes the creator an Admin member of it, so they can sign in to it next. So register at least one tenant, and be a member of it, before turning Multi on. With no active tenant every route that needs one answers 404 and nobody can sign in. Nothing stored changes with the setting, so setting it back to Single is the way out.

Background work. The workflow runner, the workflow run and webhook delivery retention sweeps, the startup pass that encrypts stored workflow credentials and the startup notice about stored validation rules visit registered tenants only, active or not, and not the default partition. That is one query per registered tenant per pass, as with database enforcement (docs/tenancy-at-the-database.md). The scheduled content sweep and the collection sync sweep visit active tenants and skip the default partition. Not changed by the mode: the job queue, which still runs a job already stored in the default partition (and, with database enforcement off, in any partition), and the startup pass that redacts stored workflow logs, which still visits the default partition. No pass logs what it skipped. The API logs one line at each start saying the mode is Multi and which passes leave those partitions alone.

The list of active tenants is cached with the domain map, for Multitenancy:CacheDuration (five minutes by default). Creating or updating a tenant through the API clears it on the instance that handled the request, and a list that instance was still loading when the write landed is not kept. Another instance keeps answering 404 for a new tenant, or serving one just switched off, until its copy expires. The token issuer and the API key check read the registry each time.

Data that is already there when you switch to Multi

Nothing is moved or deleted. Rows in the default partition, and rows under a slug with no Tenant document, stay where they are. The API no longer reaches them, and the background passes named above no longer visit them. For what is stored there that means:

  • a workflow run that is pending or running does not execute, and stays as it is;
  • scheduled content in the default partition is not published or unpublished when its time comes;
  • finished workflow runs and webhook deliveries are no longer removed when their retention window passes, so they are kept until the partition is registered or the mode goes back to Single;
  • credential parameters on stored workflows that were not yet encrypted stay as they were stored;
  • a collection sync defined in the default partition no longer runs.

The log redaction pass is the exception: it still rewrites stored workflow logs in the default partition at startup.

Run this before switching, in the API's database. It counts, for each partition no Tenant document names, the content entries, the entries with a schedule still to run, and the workflow runs that have not finished; *DEFAULT* is the default partition. With Tenancy:DatabaseEnforcement on, run it as a superuser, since the policy hides other tenants' rows from the application role.

with unregistered as (
    select 'content entries' as what, c.tenant_id
    from public.mt_doc_contents c
    union all
    select 'scheduled content entries', c.tenant_id
    from public.mt_doc_contents c
    where c.data ->> 'ScheduledPublishAt' is not null
       or c.data ->> 'ScheduledUnpublishAt' is not null
    union all
    select 'unfinished workflow runs', r.tenant_id
    from public.mt_doc_workflow_runs r
    where (r.data ->> 'Status')::integer in (0, 1)
)
select u.what, u.tenant_id, count(*) as rows
from unregistered u
where not exists (
    select 1 from public.mt_doc_tenants t where t.data ->> 'Slug' = u.tenant_id
)
group by u.what, u.tenant_id
order by u.tenant_id, u.what;

Other tenant-scoped tables (workflow definitions, files, webhook deliveries) hold rows too. The same not exists over one of them answers for that table.

To bring an unregistered partition back, create a tenant with that slug (POST /api/tenants) and give its users memberships. That works only for a slug that is a valid handle. For the default partition, and for a slug that is not a valid handle, there is no tool: run in Single while you move the data yourself.

Tokens and access

A token carries UserId, the roles resolved for that tenant, and a tenant claim (Infrastructure/Auth/TokenIssuer.cs).

The membership check runs when a token is issued, not on every request. TokenIssuer.CheckTenantAccessAsync refuses to mint a token when the tenant is registered and the user has no active Membership on it. In Single, which is the default, two cases skip the check on purpose, and both are documented at the call site:

  • the default tenant, which has no membership rows by design;
  • an unregistered slug, which is a single-tenant deployment reached over a subdomain that nobody ever created a Tenant document for. Denying it locks out the whole deployment, which is what happened the first time this check shipped.

With Tenancy:Mode set to Multi neither is skipped: both are refused before the membership check (see Tenancy mode above).

TenantAccessMiddleware then compares the token's tenant claim to the resolved tenant on each request and returns 403 on a mismatch. It exempts /api/me/* (a user has to be able to list and switch tenants from anywhere) and routes ending /public. A token with no tenant claim passes through, which is what keeps tokens issued before the claim existed working.

Who may join a tenant

A tenant's signUp policy decides what registration (POST /api/auth/register), social sign-in and invitations (POST /api/tenants/members) do for the tenant the request resolves to. Set it with PUT /api/tenants/{handle}:

signUp Registration and social sign-in Invitation
NotSet, the default as before: an account with the global User role (registration) or no role (social sign-in), and no membership works
Open the account joins the tenant with signUpRoleId and gets no global role works
InviteOnly registration answers 403; social sign-in creates no account works
Closed as InviteOnly refused with 403 when it would create an account; an existing account is still added

Open needs signUpRoleId, and a role is refused with anything else. The role cannot be SuperAdmin or carry a platform capability, since anyone with an email address can sign up. The default tenant and a slug nobody registered have no policy and behave as before. A tenant that is not Active takes no sign-ups whatever its policy.

Social sign-in of an account that already exists is a sign-in, not a sign-up, on every tenant: it joins nothing, and the issuer's membership check decides, the same as registering with an address that already has an account. Only an account the sign-in creates joins an Open tenant. A member an administrator removed or suspended keeps that row, so signing up again does not bring them back.

A registration that has to be verified records the tenant on the pending row, and verification reads the policy again: a tenant that stopped taking sign-ups in between spends the token and creates nothing.

How members sign in

A tenant's signIn rules, set whole with PUT /api/tenants/{handle}, are read when a token for the tenant is issued. Each adds to what the deployment and the member's roles already require, so the strictest applies, and leaving them unset is what a tenant did before they existed.

{ "name": "Clinic", "signIn": { "requireMfa": false, "mfaRoleIds": ["<nurse role id>"], "methods": ["password"], "sessionMinutes": 480 } }
  • requireMfa asks every member to enrol in MFA, and mfaRoleIds asks it of members holding one of those roles here. A member who has not enrolled gets the enrol-only token a role with RequireMfa already gives (mfaEnrollmentRequired on the sign-in answer). A role whose own RequireMfa is set requires MFA in every tenant; this is how one tenant requires it of a role another does not.
  • methods names the ways a sign-in may start: password (POST /api/auth/login), otp (POST /api/auth/otp/request and POST /api/auth/otp/verify, both, since a code records no tenant and one requested elsewhere could otherwise be spent here) and social. Empty allows all three. A method the tenant does not allow answers 403 before any account is read, and social sign-in is refused. The code a password sign-in emails to approve a new device is not a sign-in method, so it still works.
  • sessionMinutes, between 5 and 10080, caps the refresh token minted for the tenant at sign-in and at every refresh, and the access token when it is shorter than fifteen minutes. A refresh is capped from the moment it happens, so a session that keeps refreshing within the length keeps going.

Switching tenants with POST /api/me/switch reads the MFA rules of the tenant switched to, and not the methods, since the caller has already signed in. So methods limits how a session starts on this tenant, not how a member who also belongs elsewhere reaches it: one who signs in to another tenant by a method this one refuses can still switch here. Use requireMfa or mfaRoleIds for a rule that holds on every path.

Roles at request time

PermissionResolver asks MembershipRoles.EffectiveRoleIdsAsync for the caller's roles, which is the union of the user's global User.RoleIds and their membership roles in the current tenant, leaving out any membership role another tenant owns.

User.RoleIds was kept rather than moved. A platform SuperAdmin stays a SuperAdmin inside every tenant, which is what makes the platform-global screens keep working after switching in, and a deployment with no memberships at all behaves exactly as it did before multi-tenancy existed.

Endpoints

  • GET /api/me/tenants lists the caller's tenants (Features/Me/MyTenantsEndpoint.cs), each with roles: the roles the caller holds there (that membership's roles plus their global ones), as { id, name } sorted by name, since a role can differ per tenant.
  • POST /api/me/switch exchanges the caller's access token for one scoped to another tenant they belong to. The new token expires when the presented one would have, and the presented one is revoked. It returns no refresh token (refreshToken is empty): the one from sign-in keeps working, since a refresh mints for the X-Tenant it is sent and re-checks membership. The body names the target as { "tenant": "<handle>" }. club is an alias of tenant, kept for clients written when it was the only name, and both go through the same membership check. A request that sets both to different tenants is a 400, and so is one that sets neither. Only the JSON body is read: a tenant or club on the query string is ignored.
  • /api/tenants creates, lists and updates tenants, gated on SuperAdmin. A tenant here is its handle, name, domains and status.
  • GET /api/tenants/{handle}/public is the anonymous lookup a sign-in page needs.

The tenant profile

A tenant used to carry a public profile on its own record: LogoUrl, About, Location, LocationUrl, SocialHandle, Email and ContactUrl. From 4.6.0 the tenant's site entry holds it (site-settings.md), so a site is described in one place and gains a field without a change to the API.

Was on the tenant Is the site field
LogoUrl Logo
About About
Location Location
LocationUrl LocationUrl
SocialHandle SocialHandle
Email Email
ContactUrl ContactUrl

Reading. GET /api/tenants/{handle}/public keeps its shape, and GET /api/me/tenants keeps logoUrl. Both read the tenant's site entry the way GET /api/public/site delivers it: the published entry of a publicly deliverable type, and only fields the type marks Public. A draft is not read. GET /api/tenants and the answers to a tenant create or update no longer carry the profile. Each field is answered on its own:

The tenant's site The field answers
No published entry, or the type does not declare the field the value on the tenant record
The type declares the field and it is not Public empty
Declared, Public, and the entry holds it, blank included the entry's value, so a blank answers empty
Declared, Public, and the entry has no key for it the value on the tenant record

So the site decides wherever it has said something. Marking a field Sensitive takes it off the route, and blanking it in the entry removes it, whatever the tenant record still holds. The last row is a field nobody has set, such as Logo on a site made before the move, and is what keeps a tenant answering as it did before the migration below has run. What differs from 4.5 without the migration is a tenant whose site already said something about Logo, the one field it had: it answers with the site's logo, or with none where the site's is blank or not Public.

logoUrl, locationUrl and contactUrl answer a site value only when it is an absolute http or https address, and empty otherwise. The tenant API used to hold its two links to that on write. A site field can be declared as plain text by the tenant's own administrator, so the rule is applied on read. A value from the tenant record is answered as stored, as before.

Writing. POST /api/tenants and PUT /api/tenants/{handle} refuse a request that sets one of the seven to a value, with a 400 naming the site field. The check runs before the handle is looked up, so a value sent to a handle that does not exist is a 400 and not a 404. Absent and null mean not sent, and an update leaves what is on the tenant record alone. An empty string in an update blanks that field on the tenant record, which is how the platform removes a value the migration left behind. The profile is edited as content: update the site entry and publish it.

Who can change it. That changed. The tenant API needs the manage_tenants capability, which a SuperAdmin holds, so only the platform could set a profile. The site entry is content of the tenant, so anyone who may update and publish the site type in that tenant can, such as its own Admin. A SuperAdmin still can, from inside the tenant. Who can read it did not change: it was anonymous, and a published site entry is.

Moving what is stored. migrations/4.6.0/tenant-profile-to-site.sql moves each value into the site entry and blanks it on the tenant record. It moves a tenant only when that tenant has one published, Public entry of a publicly deliverable site type that is not event sourced, and it prints a NOTICE naming every tenant and value it leaves behind. Where the entry already holds a different value the entry wins: both sides are kept, the NOTICE names the tenant and field, and the API answers with the entry's value. A field the type declares and the entry holds blank was cleared by an editor, so the value is not copied into it. A value is also left where it is when the site field is not Public, is not a text or url field, or is a url field and the value is not an http or https address. Run the file again after a tenant it left alone publishes its site entry. Setting barako.only_tenant to a handle makes it look at that tenant only; the file's header shows how.

A value left on a tenant record is removed with PUT /api/tenants/{handle} and an empty string in that field. The file cannot tell an entry whose key was removed from one that never had it: both read as never set, and the value is moved in. This lists what is still on tenant records:

select t.data ->> 'Slug' as tenant, k.key as field
from public.mt_doc_tenants t
cross join lateral jsonb_each_text(t.data) k
where k.key in ('LogoUrl', 'About', 'Location', 'LocationUrl', 'SocialHandle', 'Email', 'ContactUrl')
  and btrim(coalesce(k.value, '')) <> ''
order by 1, 2;

Branding is not moved. It was never writable through the API and has no fixed shape to map to a site field, so GET /api/tenants and GET /api/me/tenants still return it from the tenant record.

The fallback, the seven members and Branding on Tenant are marked obsolete and go in 6.0. Before then the query above has to come back empty, or the values it lists that are still being served stop being served.

A host or module that reads those members from a Tenant in its own code finds them empty for a tenant the file moved. 4.6.0 does not need the file to have run, so it can wait until that code reads the site entry.

barakoBrew has a tenant switcher built on these.

Isolation, and its limits

  1. Marten's conjoined tenancy auto-filters every query. This is the guarantee everything else backs up.
  2. Tokens are tenant-scoped and a mismatch is refused.
  3. Membership is checked at token issue. With Tenancy:Mode set to Multi, the default partition and unregistered slugs are refused there and at resolution.
  4. Cross-tenant tests run in CI: TenantIsolationTests, CrossTenantContentApiTests, CrossTenantTokenTests, TenantResolutionTests and Features/Workflows/WorkflowTenantIsolationTests. They assert that one tenant's token and queries return nothing from another's.

Postgres row-level security is available and off by default, as Tenancy:DatabaseEnforcement (#446). Off, which is the default, a slipped application-layer filter has nothing underneath it. On, one tenant's session cannot read or write another's even when the filter is missed, enforced by the database. The boundary is DECISIONS.md D11: authorisation stays in the application, and the database enforces tenancy and nothing else.

Two limits worth knowing before relying on it. It does not catch a session opened with no tenant at all, because Marten represents that as the default tenant and Postgres cannot tell it from meaning the default partition. And it does not cover mt_events or mt_streams, which stay application-filtered.

Turning it on is not a settings change: it needs a connection role that is not a superuser, since a superuser bypasses row level security entirely. docs/tenancy-at-the-database.md has the steps and the two deployment constraints.

The honest trade-off of a shared database is that a serious bug's blast radius is every tenant. Database-per-tenant is the same Marten API and remains the escape hatch for anyone who needs hard isolation, but nothing here has been exercised that way.