-
Notifications
You must be signed in to change notification settings - Fork 6
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.
-
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.
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.
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. 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.
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/switchissues a token for another tenant the caller belongs to. -
/api/tenantscreates, lists and updates tenants, gated onSuperAdmin. -
GET /api/tenants/{handle}/publicis the anonymous lookup a sign-in page needs.
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.
- 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.
Releases
Start here
- Approval by configuration
- Configuring email
- Delivering a client project on barakoCMS
- Deploying barakoCMS on a VM
- Deploying barakoCMS on a managed platform
- Upgrading from 3.x to 4.0
- Your first module
Content
- Content type blueprints
- Choice fields
- Pushing entries to a collection
- Collections filled from outside
- Public delivery API
- Event-sourced content types
- Image variants
- Scheduling publish, unpublish and sensitivity
- SEO fields
- Site settings
- URL redirects
Security and access
- Security and compliance posture
- Scanning uploads for malware
- Where the admin keeps your session, and why
Tenancy
Operations