---
title: Multi-tenancy
path: concepts/multi-tenancy
status: published
---

# 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
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`:

```mermaid
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](./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.
