Skip to content

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 slug is 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_configs shapes — 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.

See also