Concepts

Tenant isolation

Three layers of defence so one tenant cannot read another tenant's data, even if our code has a bug.

App-layer guard

Every repository method takes tenantId as a required first argument. A custom ESLint rule fails CI if any direct DB query (TypeORM .find, .where, raw SQL) doesn't include tenant_id in the predicate.

Postgres row-level security

Every tenant-scoped table has RLS enabled. Before each query the application sets a session variable:

sql
SET LOCAL app.tenant_id = '8ad4f7c2-9e3b-4a1c-bc2f-1d8e3a9b7c4d';

And the policy on each table looks like:

sql
CREATE POLICY tenant_isolation ON messages
  USING (tenant_id::text = current_setting('app.tenant_id', true));

Forgot to scope your query? Postgres returns zero rows. Wrong tenant ID in the session? Zero rows. There's no path through the database that returns another tenant's data.

Per-tenant KMS

Provider credentials (your Twilio keys, AT secrets, FCM service accounts) are envelope-encrypted with a per-tenant master key. TheCredentialResolver service is the only code path that decrypts — enforced by ESLint. Plaintext exists only in worker memory for the duration of a single send.

Three layers:

  1. Root key — env-sourced from /etc/raven_cloud/kms.key (mode 0440)
  2. Tenant master key — generated per tenant, encrypted by the root
  3. Per-row DEK — fresh AES-256-GCM key per credential row, encrypted by the tenant master

Compromising one tenant's master key only exposes that tenant's data. Compromising the root requires host-level access, which we treat as game-over for everything anyway.

Tested in CI

Every PR runs an isolation test that creates two tenants, generates an API key for tenant A, then tries to access tenant B's data with it. The test fails if any endpoint returns even one byte of B's data. We have eight such tests today; we add one for every new endpoint.