Skip to content

Epic: Enterprise Tenant Hierarchy

Build a delegated, multi-level organization model on top of the existing tenant config (migration 004_multi_tenancy) and the existing entitlement model (migrations 195–200). Driven by a named enterprise customer.

Why this exists

A customer needs to run their own hierarchy inside our platform:

  • They are a super admin for their own organization tree.
  • The super admin creates organizations and delegates authority to org admins.
  • Org admins create departments and department admins.
  • Admins add/remove users within their org/department scope.
  • Each org has a domain that identifies it.
  • Each org may have its own SSO configuration.
  • Each org has its own branding.
  • Licensing is program-use based, not dollars/credits: the customer buys 12 months of access with “up to X PDF conversions, Y ACR processes, Z web audits,” etc.

We already have ~80% of the primitives. This epic is mostly (a) adding a department layer, (b) generalizing delegated authority to be level-scoped, (c) wiring per-org SSO, and (d) issuing a program-use package pooled at the org. It is NOT a rewrite.

What already exists (reuse, don’t rebuild)

Verified against prod (accessible-pdf-converter) 2026-08-14:

RequirementExisting primitiveStatus
Org entitytenants (slug, display_name, status, admin_email, max_users, max_monthly_pages, metadata)✅ reuse as Org
Super admin creates orgs/admin/tenants UI (list/new/detail) gated by global profiles.role='admin'✅ (but see role note)
Delegated org admintenant_admins (tenant_id, user_id, role admin/manager/viewer) + RLS✅ reuse for Org admin
Add/remove org users/tenant-admin/users, profiles.tenant_id on every user✅ org level only
Org domain identitytenant_domains (domain UNIQUE, verified, is_primary) — multiple per tenant; hostname→tenant resolution + KV cache in workers/api/src/middleware/tenant.ts✅ strong fit
Per-org brandingtenant_branding, tenant_content + /tenant-admin/branding, /tenant-admin/content✅ reuse
Program-use licensingEntitlement model: billing_packages, package_entitlements (quota_group, mode, limit, reset_period), entitlement_usage, consume_entitlement/refund_entitlement/entitlement_status (mig 195–198)✅ this IS “program uses”
Org-pooled uses + per-member capsTeam entitlement layer: team_member_quotas, team_member_usage, resolve_team_consumption (mig 199/200) — pools the OWNER’s entitlement_usage, enforces per-member caps✅ reuse, retarget to org
Native SSO infraSupabase auth.sso_providers / auth.sso_domains / auth.saml_providers (4 SSO/SAML tables present)⚠️ present, not wired to tenants

Current prod usage: 1 tenant (the default), 2 domains, 0 tenant_admins, 0 non-default-tenant users. The tenant system compiles and is deployed but has never carried real multi-tenant data — expect to shake out RLS and resolution edge cases when onboarding the first real org.

The gaps this epic closes

  1. No hierarchy / department layer. tenants.parent_id does not exist; there are no departments / department_admins tables. Tenants are flat.
  2. Delegated authority is org-wide only. tenant_admins cannot scope an admin to a single department.
  3. No per-org SSO wiring. Supabase native SAML tables exist but nothing links an SSO provider to a tenant; tenants has no SSO config. (Only LTI 1.3 exists today — LMS launch, not general SAML/OIDC.)
  4. “Super admin” today = global platform admin. A customer super-admin must own only their org tree, not the whole platform.
  5. No program-use package issued at the org level. The entitlement engine exists but no team_pool package is provisioned to an org, and there is no self-serve/admin path to grant “12 months, up to X/Y/Z.”

Design decisions

Departments = explicit table, NOT recursive tenants

The hierarchy is a fixed 3 levels (org → dept → user). A self-referential tenants.parent_id would force every existing tenant query, the resolution middleware, and every RLS policy to become hierarchy-aware — high blast radius on a system that assumes flat tenants. Instead:

  • departments (id, tenant_id FK, name, slug, status, metadata, ...)
  • department_admins (department_id, user_id, role) — mirrors tenant_admins
  • profiles.department_id UUID NULL — added alongside the existing tenant_id

Org = tenants row. Department = departments row under it. A user belongs to one org (tenant_id) and optionally one department (department_id).

Delegated authority = one level-scoped check

Collapse the “can this user manage that scope?” logic into a single helper used by BOTH the API middleware and RLS:

can_manage(actor, scope_type IN ('org','dept'), scope_id) → bool
  • Customer super admin — a new role scoped to the org tree (see below); may manage the org and every department under it.
  • Org admin — tenant_admins row; manages the org and all its departments.
  • Department admin — department_admins row; manages only that department’s users.
  • Platform super admin — global profiles.role='admin' (us); unchanged.

Customer super admin ≠ platform admin

Do not hand the customer the global profiles.role='admin' flag (it gates all of /admin). Introduce an org-owner / customer-super-admin role scoped to a tenant tree — model it as a tenant_admins role value (e.g. owner) that can_manage treats as authoritative over the whole org + its departments. This keeps the customer powerful inside their tree and powerless outside it.

Per-org SSO = Supabase native SAML, keyed by domain

This aligns exactly with “each org has a domain”: Supabase routes login to the right IdP by email domain. Add a thin link table rather than reinventing SAML:

  • tenant_sso_config (tenant_id, sso_provider_id, provider_type, status, metadata) linking a tenant to the Supabase auth.sso_providers id, plus /tenant-admin UI to register IdP metadata and map domains.
  • Plan: confirmed available. The org is on Supabase Pro, and “Auth SSO — SAML 2.0 for Projects” (SSO for our app’s end users) is offered on Pro and above. (Not to be confused with Supabase dashboard SSO, which needs Team/Enterprise — irrelevant here.)
  • SAML 2.0 only natively. Works for Azure AD, Okta, Google Workspace, ADFS, etc. If the customer’s IdP is OIDC-only, add the OIDC-broker fallback (more custom code). Confirm the customer’s IdP/protocol.
  • Cost: billed at $0.015 per SSO MAU beyond the plan quota — a real incremental cost; bake it into the annual price and record it per the cost-tracking standard. Providers are registered via the Supabase CLI / Management API (one SAML provider per org/domain), not a dashboard toggle.

Branding = reuse tenant_branding, decide department inheritance

tenant_branding / tenant_content already exist per tenant. Scope: branding is org-level; departments inherit the org’s branding (no per-department branding in v1). If a department override is later needed, add an optional department_branding — out of scope here.

Pricing / licensing = program-use package pooled at the org

The entitlement model is already a program-use system (units per product group, not dollars). Map the customer’s purchase directly:

  • Create a billing package for the deal with team_pool = true.
  • Add one package_entitlements row per product the customer bought, e.g. quota_group = 'pdf_conversion' limit X, 'acr' limit Y, 'web_audit' limit Z, each mode = 'quota'.
  • Term: annual allocation (confirmed). One allocation for the whole 12 months — mode='quota' with a one-time reset at purchase and expiry at term end (NOT per-month). entitlement_usage.period_resets_at = purchase date + 12 months; the org’s pooled counter is the owner’s entitlement_usage row (existing resolve_team_consumption behavior), retargeted from “team owner” to “org.”
  • Members/departments draw down the pool; optional per-department and per-member caps reuse team_member_quotas (retargeted to org/department scope).
  • No wallet, no cents for this customer — mode='quota' only; the legacy cents pool path stays dormant.

Open pricing question (needs customer answer): is “up to X” a single allocation for the whole 12 months, or X per month/period? Default assumption in this epic: one allocation over the 12-month term (one-time reset at purchase, expires at term end). If per-period, set reset_period accordingly — the engine already supports lazy period resets.

Scope (sub-issues + build order)

Dependency chain — build top to bottom (GitHub epic #1968, sub-issues #1969–#1976):

  1. Department data model (foundation) — #1969 — departments, department_admins, profiles.department_id; RLS; migration. Everything below depends on this.
  2. Level-scoped authority — #1970 — can_manage(actor, scope_type, scope_id) helper; introduce the org-owner role; refactor tenant_admins/department_admins RLS + the API tenant middleware to consult it.
  3. Admin UX for the hierarchy — #1971 — super-admin creates orgs (extend /admin/tenants); org admin creates departments + department admins; dept admin manages users. Extend /tenant-admin/*.
  4. Program-use package + provisioning — #1972 — billing package w/ team_pool, per-product package_entitlements (X/Y/Z), 12-month term; admin path to grant/renew it to an org; retarget resolve_team_consumption from team→org.
  5. Per-department / per-member caps — #1973 — reuse team_member_quotas / team_member_usage scoped to org + department; caps UI.
  6. Per-org SSO — #1974 — tenant_sso_config, Supabase SAML provider registration (CLI/Management API), domain→IdP mapping, /tenant-admin/sso UI. Available on current Pro plan; SAML 2.0 only; $0.015/SSO-MAU cost to track.
  7. Per-org branding rollout — #1975 — confirm tenant_branding covers the ask; departments inherit; wire the customer’s brand assets.
  8. Usage/quota dashboard — #1976 — org- and department-level “X of Y used” views (extend the existing team allowance/report endpoints); export.

Data model summary (new objects)

departments(id, tenant_id→tenants, name, slug, status, metadata, created_at, updated_at)
department_admins(id, department_id→departments, user_id→profiles, role, created_at)
profiles.department_id UUID NULL → departments(id) -- additive
tenant_sso_config(tenant_id→tenants, sso_provider_id, provider_type, status, metadata)
tenant_admins.role -- extend CHECK to add 'owner' (customer super admin)
billing_packages -- one new row for the deal, team_pool = true
package_entitlements -- one row per bought product (quota_group, mode='quota', limit, reset_period)

Reused as-is: tenants, tenant_domains, tenant_branding, tenant_content, entitlement_usage, team_member_quotas, team_member_usage, resolve_team_consumption, consume_entitlement, refund_entitlement, entitlement_status.

Resolved

  • Allocation cadence — annual allocation: one X/Y/Z pool for the whole 12-month term (not per-month). See Pricing.
  • Supabase plan / SSO — current Pro plan includes Auth SSO (SAML 2.0 for Projects). No plan upgrade needed. SAML-only; $0.015/SSO-MAU.

Open questions (need customer / product input)

  1. IdP / protocol: which SSO does the customer run? SAML 2.0 is covered natively; OIDC-only would require adding the broker fallback.
  2. User provisioning: self-serve JIT on first SSO login (auto-assign to org by email domain), admin invite, or bulk CSV? SSO domain-match makes JIT natural.
  3. Cross-department users: can a user belong to multiple departments, or exactly one? (Model assumes one; multi requires a join table.)
  4. Overage behavior when a pool is exhausted mid-term: hard block, soft warn, or notify super admin? (Engine hard-blocks by default.)
  5. Department-level quotas: required in v1, or org-pool only to start?

Non-goals (explicitly out of scope)

  • Per-department branding overrides (org branding inherited).
  • More than 3 hierarchy levels.
  • Migrating the legacy cents-based team credit pool onto this model (stays dormant).
  • Reselling / nested billing (departments do not have their own subscriptions).

Triggers / status

Filed 2026-08-14 in response to enterprise customer requirements; GitHub epic #1968 with sub-issues #1969–#1976 created 2026-08-15. Related prior work: entitlement model (mig 195–200), team management (docs/admin/access/team-management.md, issue #1347), v2 team epic #1611, original team-workspaces epic #448.