tenant
Organizations, per-user memberships, the tenant hierarchy, plan-based entitlements, and the invite lifecycle — the substrate under the two-tier tenancy model. Implements iface.TenantProvider, so auth can embed memberships in JWTs and middleware can resolve the acting tenant.
It does not own org-scoped roles or permissions; those are authz role bindings. Membership.Roles is a denormalized copy of the role names, kept for fast reads.
The two kinds
TenantKind is internal or external, and the distinction is load-bearing rather than cosmetic:
- Internal — the operator's own organizations (Tier-1).
- External — client organizations (Tier-2), which can themselves nest: an external tenant may have divisions, its own sub-tenants. The hierarchy is materialized in a
tenant_ancestorsclosure table (ADR-0001) rather than walked per query.
Per ADR-0006 this data model survives intact. What left with the removed addons is the mechanism by which Tier-2 clients consume Tier-1 services — catalog, subscribe, entitlement. A fork that sells to external clients rebuilds that layer on top of these primitives.
Provisioning policy — who may create a tenant
Two enum config keys at /admin/modules/tenant, one per tier, read at request time through a 30-second cache so edits take effect with no restart:
| Key | Tier | Modes | Default |
|---|---|---|---|
provisioning.internal.mode | internal (Tier-1) | manual · single — fail-closed | manual |
provisioning.external.mode | external (Tier-2) | open · manual — fail-open, unchanged | manual |
Tier-1 (internal) is fail-closed and never self-serve: open is not a valid internal mode, and a missing, unknown, or legacy-stored open value resolves to manual rather than opening the gate. Every Tier-1 creation path requires system.tenants.admin regardless of mode — single only adds a cardinality constraint on top of manual — at most one Tier-1 tenant may occupy a provisioning slot — it does not by itself grant creation authority. Tier-2 (external) keeps its historical fail-open behavior, unchanged by this: open lets any authenticated user create, manual restricts creation to holders of system.tenants.admin.
Both tiers default to manual, so a fresh install accepts no self-service tenant creation at all, and may legitimately run with zero internal tenants. External clients are never auto-provisioned and cannot self-create: a platform admin creates the client tenant and assigns it.
Lifecycle terminology
This module does not use "active tenant" as shorthand for every retained tenant — the two relevant predicates are explicit:
- An operational tenant has
status == activeanddeletedAt == nil. Only an operational internal tenant is eligible to be the platform default. - A tenant occupies a provisioning slot when
deletedAt == niland its status is one ofprovisioning,active, orsuspended. A suspended tenant remains part of the installation and therefore continues to occupy thesingleslot.archivedandpurgedtenants do not occupy a slot, even if a legacy row has not been soft-deleted.
CountProvisioningSlotsByKind implements the second predicate and backs the single cardinality gate, config validation, and the admin provisioning-policy read. A lifecycle check that needs an operational tenant uses an exact status predicate instead of this count.
Enforcement is split on purpose, so no creation path can slip through:
singleis a data invariant in the service — the provisioning-slot count is checked insideCreateTenant, which covers direct creation, divisions, and lazy provisioning automatically.- The permission gate in the handlers is universal for Tier-1, conditional for Tier-2 — for
kind=internalit always requiressystem.tenants.admin, independent of mode; forkind=externalit requiressystem.tenants.adminonly when the resolved mode ismanual(openstays self-serve). - Lazy provisioning honors
external.mode— a Tier-2 caller with no assigned tenant gets a409telling them an administrator must assign one, rather than silently minting one.
Platform default tenant
A single platform-global pointer (tenant_defaults, collection below) names the internal tenant used to begin operator resolution. The pointer, not a stored flag on the tenant document, is canonical — every DTO isDefault field the API exposes (the admin tenant list, the admin get-tenant response, each member row, and each row of the caller's own GET /v1/tenants — the operator console's tenant switcher) is derived at request time, resolved once per request, and never written back to Mongo. A stored column was rejected on purpose: transfer would become a two-document mutation with either a no-default window or a unique-index collision.
Only an operational internal tenant (status == active, not soft-deleted) can hold the pointer. Suspending, archiving, purging, or deleting the current default is refused with 409 tenant.default_reassignment_required — reassign the default to another tenant first, then retire the old one. Setup assigns the first default; afterwards an administrator moves it with PUT /v1/admin/tenants/default.
:::warning Default transfer requires MFA, same as purge
PUT /v1/admin/tenants/default, DELETE /v1/admin/tenants/{tenantId}, and POST /v1/admin/tenants/{tenantId}/purge are the only three platform-admin tenant routes gated behind system.tenants.admin plus an MFA step-up — every other admin route needs only the permission. Default reassignment and tenant destruction are equally consequential, so neither is protected more weakly than the other.
:::
Routes
| Surface | Paths | Gate |
|---|---|---|
| Global | GET/POST /v1/tenants, POST /v1/tenants/accept-invite | authenticated |
| Per-tenant read | /v1/tenants/{tenantId}, …/members, …/divisions | tenant.read |
| Per-tenant mutation | update, archive, plan change, member removal, invites, divisions | tenant.read + MFA step-up |
| Platform admin — reads + non-destructive | most of /v1/admin/tenants and /v1/admin/clients | system.tenants.admin |
| Platform admin — destructive / default | PUT /v1/admin/tenants/default, DELETE /v1/admin/tenants/{tenantId}, POST /v1/admin/tenants/{tenantId}/purge | system.tenants.admin + MFA step-up |
| Tier-2 self-service | /v1/me/billing-identity, /v1/me/italian-billable | authenticated, client surface |
Every per-tenant mutation sits behind a step-up because each one can transfer ownership-adjacent data, change entitlements, or destroy the tenant. A password-only token gets 401 step_up_required and retries after verifying.
The platform-admin group is gated by a system permission rather than per-org membership, so operators manage every tenant without joining each one. It backs /admin/internal/tenants (Tier-1) and /admin/clients (Tier-2). Within it, default transfer and the two tenant-destroying routes carry the same MFA step-up as the per-tenant mutation group — see Platform default tenant.
:::warning Purge is irreversible
POST /v1/admin/tenants/{tenantId}/purge crypto-shreds the tenant's KMS key. Archiving (DELETE) is the recoverable operation; purge is not.
:::
Permissions
| Key | System | Grants |
|---|---|---|
tenant.read | — | Read tenant details |
tenant.update | — | Update name, slug, settings |
tenant.delete | — | Archive the tenant |
tenant.plan.update | — | Change plan and features |
tenant.member.read | — | List members |
tenant.member.invite | — | Invite new members |
tenant.member.remove | — | Remove members |
system.tenants.admin | ✅ | Administer every tenant platform-wide |
Storage
tenants, tenant_memberships, tenant_invites (a real TTL index — Mongo reaps an invite the moment it expires), tenant_ancestors (the hierarchy closure table), tenant_entitlements (the capability projection behind iface.AccessProvider), and tenant_defaults (platform-global, no tenantId; a unique index on kind makes the pointer swap a single atomic update — see Platform default tenant).
GDPR
Registers an iface.PIIProducer for the subject tenant. The subject's personal data here is their memberships — which orgs, what roles. The organizations themselves are not the subject's data and are left intact. Erasure deletes the membership rows under either mode, since a membership row is the user→org linkage with no anonymizable residue.
:::note No tenant on a fresh install
Setup creates no tenant. The first administrator is a super_admin, which is a platform system role and tenant-independent. The setup wizard's org step is the first — and skippable — creation point.
:::