multi tenancy - BaryoDev/barakoCMS GitHub Wiki
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.
-
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
WHEREclause somebody has to remember. -
One global identity. A
Userexists once across all tenants (Models/User.cs). -
Membership carries per-tenant roles.
Membershiplinks a user to a tenant slug with the roles they hold there (Models/Membership.cs). Note it storesTenantSlug, a string, not a tenant id. -
A
defaulttenant.Tenant.DefaultSlugis"default", andTenantSessionFactoryopens a tenant-less session for it, so a single-tenant deployment keeps working with no tenant rows and no migration.
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 |
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 are global, not per tenant. A role is a named permission set; which roles a person holds
in a tenant is what Membership.RoleIds carries. Defining the role once and assigning it per
tenant is the split, rather than every tenant owning its own copy of "Editor".
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.
TenantResolutionMiddleware resolves, in order:
- the
X-Tenantheader, - a registered custom domain (
Tenant.Domains, looked up throughITenantDomainSource), - the host's leading subdomain, ignoring the infra labels
www,app,apiandadmin, - the
defaulttenant.
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.
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 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
defaultpartition; - a slug with no
Tenantdocument 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.
{ "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
defaultpartition (no header, no registered domain and no subdomain, orX-Tenant: default); - a slug with no
Tenantdocument; - a tenant whose
IsActiveis false.
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.
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
defaultpartition 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
defaultpartition 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.
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
Tenantdocument 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.
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.
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.
-
GET /api/me/tenantslists the caller's tenants (Features/Me/MyTenantsEndpoint.cs). -
POST /api/me/switchexchanges 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 (refreshTokenis empty): the one from sign-in keeps working, since a refresh mints for theX-Tenantit is sent and re-checks membership. The body names the target as{ "tenant": "<handle>" }.clubis an alias oftenant, 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: atenantorclubon the query string is ignored. -
/api/tenantscreates, lists and updates tenants, gated onSuperAdmin. A tenant here is its handle, name, domains and active flag. -
GET /api/tenants/{handle}/publicis the anonymous lookup a sign-in page needs.
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.
- Marten's conjoined tenancy auto-filters every query. This is the guarantee everything else backs up.
- Tokens are tenant-scoped and a mismatch is refused.
- Membership is checked at token issue. With
Tenancy:Modeset toMulti, the default partition and unregistered slugs are refused there and at resolution. - Cross-tenant tests run in CI:
TenantIsolationTests,CrossTenantContentApiTests,CrossTenantTokenTests,TenantResolutionTestsandFeatures/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.
Generated from docs/multi-tenancy.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.