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.
| Before | After | |
|---|---|---|
| Tenant | An operator-run deployment ("EU Central") holding many clients | One client company ("Celtic"): its accounts, catalog, config and staff. Operator tenants (staff only) still exist |
| Account | A client company ("Celtic") | A billing account of the client ("Celtic", "Celtic DEMO", "Celtic Migration") |
| Client staff | Account-scope membership(s) inside a shared tenant | Tenant-scope membership of their own tenant; sees all its accounts |
| Account-scope membership | The only grant a client user had | Optional 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-rootsnapshots 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 withparent_idset 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/*):
| Area | Endpoints | Notes |
|---|---|---|
| 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/invites | The 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 keys | GET /platform/api-keys, DELETE /platform/api-keys/{id} | Keys are created and rotated inside their tenant |
| Roles | /platform/roles | The platform's own roles; each tenant's roles are managed in the tenant (/roles) |
| Logs | GET /platform/audit, /platform/events, /platform/import-runs | Audit includes tenant-less rows (sign-ins, tenant lifecycle, platform config, superadmin changes); filter tenant_id, platform_only, tags, domain |
| Monitoring | GET /platform/system | DB reachability, Stripe health, counts, running and failed events (24h), pending invites, scheduled jobs |
| Global settings | /platform/integrations, /platform/connectors, /platform/config | Inherited 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 tofalseonly for local development).GET /mereportsmfa_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 /tenantswithtemplate_tenant_id(+ optionalparts) orPOST /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/switchinto a tenant the admin is not a member of is recorded asview_as(tenant-less, plus aplatform_access-taggedswitchin the tenant's own feed); opening the console recordsroot_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/announcementsand 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_scopebacksdeps.own_account_ids_if_scopedand 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 receivesNone(staff) or a list of account ids. - Invites and CSV import.
account_idis 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.pycreates 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_idstill holdsAccount.account_id,/accountsis 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.