Multi-Tenancy and Row-Level Security¶
Pulse is multi-tenant to the core. Every tenant's schemas, datasets, events, and lake tables are isolated, and that isolation is enforced by PostgreSQL row-level security (RLS) — not by application WHERE clauses that a bug could omit.
The model¶
Every tenant-owned table carries a tenant_id column and an RLS policy:
POLICY tenant_isolation
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
current_tenant_id() is a SQL function that reads a per-transaction setting, app.current_tenant_id. On each request the tenant middleware resolves the tenant (from the X-Tenant-ID header or a bearer key) and binds it inside the open transaction:
SELECT set_config('app.current_tenant_id', :tenant_id, true);
set_config(..., true) is the transaction-local form (SET LOCAL can't take a bound parameter). With the GUC set, every query the request issues is automatically filtered to that tenant — USING scopes reads, WITH CHECK prevents writing a row that belongs to someone else.
The connection-role split (the part that bites)¶
Never run the app as a superuser
A PostgreSQL superuser — or any BYPASSRLS role — bypasses RLS entirely. FORCE ROW LEVEL SECURITY only subjects the table owner, not superusers. If the application connects as a superuser, every policy above is silently ignored and tenants see each other's data.
Pulse therefore uses two roles:
| Role | Connection | Used by | RLS |
|---|---|---|---|
pulse_app | PULSE_DATABASE_URL | the API and the worker at runtime | subject to RLS (non-superuser, NOBYPASSRLS) |
pulse | PULSE_ADMIN_DATABASE_URL | migrations, seeds, role management | superuser (DDL only) |
This split is mandatory. It was learned the hard way: connecting the app as pulse leaked every tenant's data across tenants until the cross-tenant isolation test caught it. Policies alone prove nothing — always verify the runtime role is non-superuser and NOBYPASSRLS.
The same pulse_app role and per-query tenant binding are used by the ingestion worker, so the asynchronous write path isolates tenants exactly as the API does.
Isolation beyond Postgres¶
The tenant_id is the partition key throughout the stack:
- Kafka topics:
pulse.{tenant_id}.events.raw/.events.dlq— one set per tenant, pre-created on tenant creation. - Iceberg namespaces:
pulse_{tenant_id}— one namespace per tenant; datasets with the same slug in different tenants are physically distinct tables. - Lake preview: the dataset row is resolved under RLS before DuckDB runs, so the query engine is never handed a foreign table location.
See also¶
- PDM Schemas — the tenant-scoped registry.
- Datasets and the Data Lake — per-tenant Iceberg namespaces.
- ADR-0002 (internal) records the full isolation decision.