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:
SET LOCAL app.tenant_id = '8ad4f7c2-9e3b-4a1c-bc2f-1d8e3a9b7c4d';And the policy on each table looks like:
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:
- Root key — env-sourced from
/etc/raven_cloud/kms.key(mode 0440) - Tenant master key — generated per tenant, encrypted by the root
- 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.