Skip to main content

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_ancestors closure 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:

KeyTierModesDefault
provisioning.internal.modeinternal (Tier-1)manual · single — fail-closedmanual
provisioning.external.modeexternal (Tier-2)open · manual — fail-open, unchangedmanual

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 == active and deletedAt == nil. Only an operational internal tenant is eligible to be the platform default.
  • A tenant occupies a provisioning slot when deletedAt == nil and its status is one of provisioning, active, or suspended. A suspended tenant remains part of the installation and therefore continues to occupy the single slot. archived and purged tenants 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:

  1. single is a data invariant in the service — the provisioning-slot count is checked inside CreateTenant, which covers direct creation, divisions, and lazy provisioning automatically.
  2. The permission gate in the handlers is universal for Tier-1, conditional for Tier-2 — for kind=internal it always requires system.tenants.admin, independent of mode; for kind=external it requires system.tenants.admin only when the resolved mode is manual (open stays self-serve).
  3. Lazy provisioning honors external.mode — a Tier-2 caller with no assigned tenant gets a 409 telling 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

SurfacePathsGate
GlobalGET/POST /v1/tenants, POST /v1/tenants/accept-inviteauthenticated
Per-tenant read/v1/tenants/{tenantId}, …/members, …/divisionstenant.read
Per-tenant mutationupdate, archive, plan change, member removal, invites, divisionstenant.read + MFA step-up
Platform admin — reads + non-destructivemost of /v1/admin/tenants and /v1/admin/clientssystem.tenants.admin
Platform admin — destructive / defaultPUT /v1/admin/tenants/default, DELETE /v1/admin/tenants/{tenantId}, POST /v1/admin/tenants/{tenantId}/purgesystem.tenants.admin + MFA step-up
Tier-2 self-service/v1/me/billing-identity, /v1/me/italian-billableauthenticated, 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

KeySystemGrants
tenant.readRead tenant details
tenant.updateUpdate name, slug, settings
tenant.deleteArchive the tenant
tenant.plan.updateChange plan and features
tenant.member.readList members
tenant.member.inviteInvite new members
tenant.member.removeRemove members
system.tenants.adminAdminister 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. :::