Skip to main content

Tenant per client company

Status and decision​

Decided 2026-10-18. The Tenant -> Account -> Customer hierarchy and every table stay exactly as they are (tenant_id on every owned row, accounts, memberships); only the meaning of the two upper levels changes. There is no production data, so there is no data migration of existing rows: environments are re-seeded (mise run db:reseed). The only schema change is making tenant_id nullable on integrations, connectors and tenant config (null = the console) plus connectors.parent_id.

BeforeAfter
TenantAn operator-run deployment ("EU Central") holding many clientsOne client company ("Celtic"): its accounts, catalog, config and staff. Operator tenants (staff only) still exist
AccountA client company ("Celtic")A billing account of the client ("Celtic", "Celtic DEMO", "Celtic Migration")
Client staffAccount-scope membership(s) inside a shared tenantTenant-scope membership of their own tenant; sees all its accounts
Account-scope membershipThe only grant a client user hadOptional restriction to a subset of the tenant's accounts

Catalog data (plans, rates, tariff categories, connectors, escalation) is tenant-owned, so each client tenant carries its own copy. Global configuration is the exception - see the console.

Console (platform scope)​

Superadmins' global settings are not a tenant. They live in the console: rows of the same tables the tenants use, with tenant_id IS NULL (integrations, connectors, tenant config - app/services/scope.py::in_scope). The API exposes them under /api/v1/platform/* (app/api/v1/routes/platform.py), gated by the platform:read / platform:manage permissions (superadmin only, MFA required), with no X-Tenant-ID; the dashboard shows them under /platform/* behind the "Console" entry of the tenant switcher.

  • Config defaults: effective config = code defaults < platform config (/platform/config) < the tenant's own.
  • Integrations (e.g. Stripe): a tenant with no row of its own inherits the platform's (IntegrationRead.inherited = true, secrets never returned; the Stripe connectivity check uses it). Saving the provider in the tenant creates its own override (custom credentials); POST /integrations/{provider}/copy-from-root snapshots the console's settings into the tenant. A tenant's own row always wins, even if disabled.
  • Connectors (e.g. Transatel): a platform connector (/platform/connectors) holds the global connection (credentials, base URL) and is a template - it never syncs or schedules. A tenant creates its own connector with parent_id set and only supplies an overlay of extendable settings; see Connectors.
  • Tenant routes can't reach platform rows (they filter on the tenant id), and an API key can never call /platform/*.

What superadmins manage from the console​

Everything under /api/v1/platform/* (all superadmin only; dashboard /platform/*):

AreaEndpointsNotes
Tenants (clients)GET /platform/tenants (+ /tenants create/rename/delete, /tenants/{id}/memberships)List carries counts (accounts, subscribers, members, connectors, API keys). Lifecycle changes are audited tenant-less
Users & access/users?all_tenants=true, PUT /platform/users/{id}/superadmin, GET /platform/invitesThe last active superadmin can't be demoted, and a customer login can't hold the role. Invites are created/resent/revoked through /invites with the target tenant's X-Tenant-ID
API keysGET /platform/api-keys, DELETE /platform/api-keys/{id}Keys are created and rotated inside their tenant
Roles/platform/rolesThe platform's own roles; each tenant's roles are managed in the tenant (/roles)
LogsGET /platform/audit, /platform/events, /platform/import-runsAudit includes tenant-less rows (sign-ins, tenant lifecycle, platform config, superadmin changes); filter tenant_id, platform_only, tags, domain
MonitoringGET /platform/systemDB reachability, Stripe health, counts, running and failed events (24h), pending invites, scheduled jobs
Global settings/platform/integrations, /platform/connectors, /platform/configInherited by tenants, see above

Superadmin safeguards and operations​

  • MFA is required. Every /platform/* endpoint, and tenant create/rename/delete, answers 403 for a superadmin whose account has no MFA (SUPERADMIN_REQUIRES_MFA, default on; set it to false only for local development). GET /me reports mfa_enrollment_required, the dashboard shows a "set up MFA" page instead of the console, and a superadmin cannot turn MFA off. MFA itself stays a per-user setting that anyone can enable under Settings > Security.
  • Deleting a tenant soft-deletes it and switches its background work off: its data source connectors are disabled and their scheduled syncs unscheduled, and the ticket escalation and notification digest jobs skip it. The response (TenantDeleteResult) and the dashboard's delete dialog say how many were stopped. Its API keys and sessions stop working because the tenant no longer resolves.
  • Tenant templates. Per-tenant settings that have no global default - config including branding, escalation matrix, tariff categories - are copied from an existing tenant: POST /tenants with template_tenant_id (+ optional parts) or POST /platform/tenants/{id}/apply-template. Config is replaced; escalation entries and tariff categories the target lacks are added, nothing is removed.
  • API keys of every tenant can be listed, revoked, and - through the target tenant's X-Tenant-ID - created and rotated from the console.
  • Access is logged. /tenants/switch into a tenant the admin is not a member of is recorded as view_as (tenant-less, plus a platform_access-tagged switch in the tenant's own feed); opening the console records root_view_enter (POST /platform/access); superadmin grants, tenant lifecycle, template applications and platform config changes are tenant-less audit rows.
  • Announcements. A platform-wide banner (info/warning/critical, optional time window) managed under /platform/announcements and shown to every signed-in user in every tenant (GET /announcements, 5-minute refresh, dismissable per browser session).

Not inherited: import mappings and the escalation matrix remain strictly per tenant. Stripe has no tenant-extendable settings today, so a tenant either inherits the platform's account as-is or brings its own (override / copy-from-root).

What changed in core-api​

  • Scoping. membership_service.list_account_scope backs deps.own_account_ids_if_scoped and the search scope: a client user with no account-scope rows gets every live account of the tenant (including ones created later); with any account-scope row they get only those. Every downstream route (accounts, subscribers, customers, lines, tickets, audit, groups) is unchanged - it still receives None (staff) or a list of account ids.
  • Invites and CSV import. account_id is optional for client roles (other kinds reject it). Omitted = tenant-wide access; given = restricted to that account.
  • Account members (/accounts/{id}/members) now manage restricted members: the grant is an account-scope row, which limits the user to the account(s) listed.
  • Search. A client user without an account restriction searches the tenant's members; a restricted one only finds colleagues sharing an account (app/search/scope.py::_user_scope).
  • Seed. scripts/seed_data.py creates one tenant per demo client (Celtic with accounts "Celtic", "Celtic DEMO", "Celtic Migration"; HIBS, ARVO, Rovers, Charity, HIS with one each). [email protected] belongs to Celtic only; operators belong to every client tenant.

Not changed​

  • Table, column and route names: subscriber_groups.customer_id still holds Account.account_id, /accounts is still /accounts. Renaming storage is a separate, versioned decision.
  • Roles were redone separately: see Roles and scopes.
  • Platform-level tenant provisioning stays platform-admin only (POST /tenants): creating a client now means creating a tenant, then its accounts and inviting its first admin.