Self-hosting
Tenant isolation
How is each tenant isolated at every hop?
A tenant is a workspace, which is a Zitadel organisation. Its organisation id is the
tenant_id at every layer, and it is always taken from something the server verified, never
from the request body of a user-facing call.
Per hop
| Hop | Where the tenant comes from | What enforces it | Code |
|---|---|---|---|
| Sign-in | urn:zitadel:iam:user:resourceowner:id in the id token (OIDC_TENANT_CLAIM) | Zitadel organisation; the server keeps the session and gives the browser an encrypted __Host-sid cookie | apps/web/src/server/auth/ |
| Control plane (Postgres) | withTenant() sets app.tenant_id for the transaction | Every tenant table has FORCE ROW LEVEL SECURITY and a tenant_isolation policy on tenant_id; an unset setting matches no rows. The server checks at start-up that every declared table has row security and exits if not | db/rls.sql, db/client.ts |
| Semantic layer (Cube) | A JWT the server mints for the session's workspace, signed with CUBEJS_API_SECRET | checkAuth verifies it; queryRewrite strips any *.tenant_id filter the caller sent and adds <cube>.tenant_id = <tenant> for every cube the query touches, and rejects a query that touches none; schema and pre-aggregations are partitioned by app id tenant_<id> | data/cube/cube.js |
| Cube to StarRocks | Cube's security context | Cube connects as the workspace's ro_<slug> user, SELECT on its own database only; cube_svc is the fallback before that user exists | cube.js, /api/internal/cube/creds |
| SQL editor | The session, plus a project the user may run SQL in | Runs as p_<project id>, granted SELECT table by table on that project's datasets and the tenant-wide ones; only SELECT and WITH statements; per-workspace hourly budget in Valkey | server/sql.ts, dataplane readonly-grants |
| Warehouse data | The workspace slug | One database per workspace, tenant_<slug>; every row also carries tenant_id and _source_id set by the dataplane | services/dataplane/src/ddl.ts, load.ts |
| BYO dbt | The session | dbt_<slug> is granted only on tenant_<slug> | dbt-admin.ts |
| File upload | Ticket from getUploadTicket, HS256, 5 minutes, after a write check on the source | The dataplane loads only into the ticket's workspace and source | server/upload.ts, dataplane auth.ts |
| MCP | vmcp_ token → its workspace and user | A token_lookup policy lets the lookup read only the row whose hash it presents; then normal row security as that user | ai/mcp-tokens.ts |
| Object storage | The workspace id | Staged files under uploads/<tenant id>/<source id>/, in a bucket the svc-dataplane account alone can reach | stage.ts |
| Valkey, Vault | NORIMS_ENV and the slug | Keys prefixed by environment; tenant secrets under secret/tenants/[<env>/]<slug>/ | sql.ts, secrets/vault.ts |
Roles inside a workspace
Server-side only; the UI hiding a control is presentation.
| Vocabulary | Values | Gates |
|---|---|---|
Workspace role (users.role) | owner, admin, member | Workspace settings, members, tenant-wide sources |
Scope role (project_members, dashboard_grants) | admin, editor, viewer | Which project and dashboard surfaces a user reaches; the action-to-role matrix is server/auth/permissions.ts |
Deliberate cross-tenant readers
| Reader | Sees | Why |
|---|---|---|
internal_reader policies (app.internal_reader) | The tenant list, due dashboards, stale ingestion runs | Cube's scheduled refresh, scheduled publishing, run sweeps |
backup role | Every row, SELECT only | The nightly pg_dump --enable-row-security; a table without a backup_reader policy stops the server at start-up |
| Service tokens | Any workspace named in the body | Internal callers of the dataplane and analytics-web; see below |
What does not isolate by tenant
| Credential | Reach | Keep it |
|---|---|---|
INGEST_TOKENS_<NAME> | Every dataplane /api/internal/* endpoint, for any tenantId or database it names, including creating warehouse users and tearing a workspace down | inside the cluster; never with a customer |
UPLOAD_TOKEN_SECRET | Signs upload tickets for any workspace | in Vault, on analytics-web and dataplane only |
CUBE_SERVICE_TOKEN | Any workspace's schema and read-only warehouse credential through /api/internal/cube/* on the app host | Cube and analytics-web only |
CUBEJS_API_SECRET | Mints Cube tokens for any workspace | Cube and analytics-web only |
dataplane_svc, cube_svc | All warehouse databases | in Vault |
Preview and prod share the StarRocks cluster and these two warehouse users. What keeps preview
off prod's data is naming (preview_tenant_<slug>, preview_-prefixed users) and the
application scoping every name by NORIMS_ENV, not a grant.
Cube's pre-aggregations in CubeStore are not removed when a workspace is torn down; the StarRocks database, its users and its staged files are.