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:
| Requirement | Existing primitive | Status |
|---|---|---|
| Org entity | tenants (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 admin | tenant_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 identity | tenant_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 branding | tenant_branding, tenant_content + /tenant-admin/branding, /tenant-admin/content | ✅ reuse |
| Program-use licensing | Entitlement 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 caps | Team 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 infra | Supabase 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
- No hierarchy / department layer.
tenants.parent_iddoes not exist; there are nodepartments/department_adminstables. Tenants are flat. - Delegated authority is org-wide only.
tenant_adminscannot scope an admin to a single department. - No per-org SSO wiring. Supabase native SAML tables exist but nothing links
an SSO provider to a tenant;
tenantshas no SSO config. (Only LTI 1.3 exists today — LMS launch, not general SAML/OIDC.) - “Super admin” today = global platform admin. A customer super-admin must own only their org tree, not the whole platform.
- No program-use package issued at the org level. The entitlement engine
exists but no
team_poolpackage 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)— mirrorstenant_adminsprofiles.department_id UUID NULL— added alongside the existingtenant_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_adminsrow; manages the org and all its departments. - Department admin —
department_adminsrow; 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 Supabaseauth.sso_providersid, plus/tenant-adminUI 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_entitlementsrow per product the customer bought, e.g.quota_group = 'pdf_conversion'limit X,'acr'limit Y,'web_audit'limit Z, eachmode = '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’sentitlement_usagerow (existingresolve_team_consumptionbehavior), 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):
- Department data model (foundation) — #1969 —
departments,department_admins,profiles.department_id; RLS; migration. Everything below depends on this. - Level-scoped authority — #1970 —
can_manage(actor, scope_type, scope_id)helper; introduce the org-owner role; refactortenant_admins/department_adminsRLS + the API tenant middleware to consult it. - 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/*. - Program-use package + provisioning — #1972 — billing package w/
team_pool, per-productpackage_entitlements(X/Y/Z), 12-month term; admin path to grant/renew it to an org; retargetresolve_team_consumptionfrom team→org. - Per-department / per-member caps — #1973 — reuse
team_member_quotas/team_member_usagescoped to org + department; caps UI. - Per-org SSO — #1974 —
tenant_sso_config, Supabase SAML provider registration (CLI/Management API), domain→IdP mapping,/tenant-admin/ssoUI. Available on current Pro plan; SAML 2.0 only; $0.015/SSO-MAU cost to track. - Per-org branding rollout — #1975 — confirm
tenant_brandingcovers the ask; departments inherit; wire the customer’s brand assets. - 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) -- additivetenant_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 = truepackage_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)
- IdP / protocol: which SSO does the customer run? SAML 2.0 is covered natively; OIDC-only would require adding the broker fallback.
- 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.
- Cross-department users: can a user belong to multiple departments, or exactly one? (Model assumes one; multi requires a join table.)
- Overage behavior when a pool is exhausted mid-term: hard block, soft warn, or notify super admin? (Engine hard-blocks by default.)
- 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.