Skip to content

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