Multi-tenancy
ScaiControl is a three-level hierarchy: Platform → Partner → Tenant. Every billable resource hangs off one of those levels. Understanding the model is the prerequisite to everything else here.
The three levels#
| Level | Examples | Owns |
|---|---|---|
| Platform (operator) | ScaiLabs B.V. | Service registry, default plans, the codebase |
| Partner | ScaiLabs B.V. (as itself) or any reseller | A set of tenants, optional own billing identity, branding |
| Tenant | A company that subscribed | Subscriptions, billing profile, invoices, users |
A user is always attached to a tenant. A tenant is always attached to a partner. The operator IS a partner — typically partner_id equal to the operator's own partner_id so its own tenants flow through the same plumbing.
How identity flows#
ScaiControl does not authenticate users itself. ScaiKey issues JWTs containing sub (user ID), tenant_id, partner_id, roles, and permissions. Every request to /api/v1/* either:
- Carries the JWT → request gets a
CurrentUserwith those claims, plus an enriched permission set merged from local roles via/auth/me. - Lacks a JWT → only public endpoints (
/auth/config,/catalog/services) respond; everything else returns 401.
The tenant_id from the JWT is automatically pushed into the SQLAlchemy session's info dict and any query touching a TenantScopedModel subclass is rewritten to add WHERE tenant_id = <jwt.tenant_id>. This makes cross-tenant leaks at the query layer impossible — you'd have to bypass the ORM entirely to hit another tenant's rows.
Tenant-scoped vs unscoped tables#
1 2 | |
Roughly: anything that belongs to ONE tenant inherits from TenantScopedModel. Examples:
subscriptionsinvoices+invoice_line_itemstenant_billing_profilespayment_methodsnotificationsusage_events
Unscoped (shared across tenants): the service registry, plans, service packs, invoice/email templates, webhook subscribers, audit log, the user/group/partner directories.
How a request reaches the right tenant#
The dependency chain in dependencies.py:
So tenant-facing endpoints get tenant-isolated DB access automatically; admin endpoints opt in to cross-tenant visibility by using DbDep directly.
Admin vs tenant in the UI#
The same SolidJS bundle serves both. RBAC decides what the user sees:
super_admin— full access. Sees the admin sidebar and bypasses tenant filtering.partner_admin— manages tenants belonging to their partner. Sees a scoped admin surface.tenant_admin— manages billing for their own tenant. Sees the customer portal plus a billing-profile editor.- No admin role — just the customer portal.
In the routes, require_permission("admin:billing") etc. gates the admin endpoints. super_admin is a bypass; everything else is checked against the merged permission set.
Partner-level vs tenant-level billing#
A tenant is the typical buyer of an invoice. The billing profile (tenant_billing_profiles) carries the buyer-side information: company name, EU VAT number, address, contact email, preferred language for invoices.
In some setups a partner buys directly (partner-direct billing). The partner's PartnerConfiguration carries the seller fields (legal name, VAT, IBAN/BIC) and, in this case, also the buyer fields. The two patterns can coexist for the same partner — partner-direct for itself and tenant-level for its customers.
The billing profiles concept page goes deeper.
Cross-tenant operations#
A few operations legitimately span tenants:
- The heartbeat monitor scans the service registry — registry-wide, no tenant scope.
- The event dispatcher emits webhooks for all tenants from a single outbox table.
- Admin "list all invoices" obviously needs to cross tenants.
These all use DbDep (unscoped) and explicitly filter tenant_id only when an admin filter is supplied.
Cardinality#
In a typical ScaiLabs deployment you see roughly:
- 1 platform operator
- 5–50 partners (most being "the operator itself" for direct-sold tenants; the rest being resellers)
- 10–1000s of tenants per partner
- 1–100s of users per tenant
The model scales linearly in each dimension; the bottleneck is the heartbeat monitor's loop, which is sized for hundreds of registered services, not tens of thousands of tenants.