Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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:

  1. Carries the JWT → request gets a CurrentUser with those claims, plus an enriched permission set merged from local roles via /auth/me.
  2. 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#

python
1
2
class TenantScopedModel(Base):
    tenant_id: Mapped[str] = mapped_column(String(36), index=True)

Roughly: anything that belongs to ONE tenant inherits from TenantScopedModel. Examples:

  • subscriptions
  • invoices + invoice_line_items
  • tenant_billing_profiles
  • payment_methods
  • notifications
  • usage_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:

flowchart LR JWT["JWT"] --> GCU["get_current_user()"] GCU --> CU["CurrentUser<br/>user_id, tenant_id, partner_id,<br/>roles, permissions"] CU --> TDB["<b>TenantDbDep</b><br/>pushes tenant_id into session.info<br/><i>/billing, /subscriptions, …</i>"] CU --> DB["<b>DbDep</b><br/>unscoped session<br/><i>/admin (cross-tenant)</i>"]

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.

Updated 2026-05-18 01:48:39 View source (.md) rev 2