Multi-tenancy¶
Status: Current | Last reviewed: 2026-09-29
How tenants are isolated, what crosses the boundary, and what does not.
The tenant root¶
Every isolation-relevant table carries a tenant_id foreign key into tenants. The tenants.slug column is the URL- and filesystem-safe identifier used in:
- Catalog workspace directories:
catalog-tenant-<tenant_slug>-<catalog_slug>. - API URL paths.
- Credential namespacing.
Warning: renaming a tenant's
slugis blocked while it owns catalogs (init-script 152 enforces this). Plan around it; do not try to fight the constraint.
Row-level security¶
Tenant-scoped tables enable Postgres RLS. The application sets the current tenant on each connection via SET LOCAL app.current_tenant = '<uuid>', and policies on each table restrict reads and writes accordingly.
The pattern lives in apps/ai-manager/lib/db.ts and is mirrored on the Python side. New tables should follow it: add the tenant_id column, the FK, and the policy in the same migration.
What is shared¶
Some tables are intentionally global:
integration_types— catalog of connector types.- Reference data (locales, currencies, country codes).
surface_configsshapes — the schema, not the rows.
These are read-only from app code; they change via init-script migrations.
Platform administrators and active tenant scope¶
Agents, workflows, and conversations belong to exactly one tenant. Admin, Builder and Viewer assignments apply only within their tenant. Platform Admin receives all permissions in every active tenant.
Platform administrators can select every active tenant; they do not need an
explicit tenant_memberships row for each one. The normal authoring APIs still
scope agent reads and writes to that selected tenant. This keeps the tenant
shown in the sidebar consistent with the agents, integrations, and other
resources shown by the application. A deep link to an agent in another tenant
returns not found until the administrator switches to that tenant.
The collaboration gateway applies the same rule before joining an agent or
workflow Y.Doc room: platform-admin status bypasses the permission check, not
the selected-tenant ownership check. This prevents a direct WebSocket room URL
from reopening data that the HTTP page correctly rejected.
The remaining exceptions are operational:
- Purpose-built platform-administration queries may read across tenants. The generic Postgres platform-admin RLS policy supports those queries, but normal agent helpers add an active-tenant predicate as defense in depth.
- The status page reads cross-tenant aggregate counts (no row content).
Per-tenant secrets¶
Encrypted credentials in integrations.encrypted_config are tenant-scoped by FK. The encryption key (MASTER_ENCRYPTION_KEY) is global to the deployment, not per-tenant. Rotation is a deployment-wide operation; see the rotate-master-encryption-key runbook.
Per-tenant deployment¶
A tenant typically does not get its own cluster. They get rows. Cluster-per-tenant is an option for high-segregation customers and is implemented by deploying the umbrella chart into a fresh namespace with a single tenant seeded.
Adding a new tenant¶
In Vanguard:
# In ai-manager, profile dropdown → Admin → Tenants → New tenant
# Or via SQL:
INSERT INTO tenants (id, name, slug) VALUES (gen_random_uuid(), 'Acme', 'acme');
For Sentinel and customer environments, use the Platform Admin console
(profile dropdown → Admin), which platform (super) admins reach at /admin. See
ai-manager → Platform Admin console.
The first user is provisioned via Authentik group → tenant mapping. Authentik is
authoritative for tenant membership: a user's groups named
tenant:<slug>:<role> become their trueserve_tenants claim on login, and
syncTenantMemberships() deactivates any local membership not backed by a
group. The admin console therefore dual-writes to Authentik groups so edits
survive the next login — a DB-only change would silently revert. See also
authentik-connect-sync.