Application map / Runbooks / RLS activation

RLS Activation Runbook

Switching the app's request path from the owner database connection onto the restricted gallopify_app role, so row-level security actually filters.

Not activated in production verified 2026-09-07

Live evidence from that verification: APP_DATABASE_URL is absent from production (and dev) Doppler; the production session is neondb_owner with rolbypassrls = true; RLS policies exist on the domain tables (ENABLE, deliberately not FORCE); the restricted role gallopify_app is provisioned with correct grants but has no live sessions and no usable stored password. The application-layer can_see_property predicate is the sole live authorization gate (see identity & authorization). This runbook stays open until enforcement is proven live.

FastAPI request path two engines, chosen per session type raw sessions · migrations — always RLS-scoped request sessions, once the secret is set Owner engine DATABASE_URL neondb_owner — bypasses RLS unconditionally today: every session lands here Restricted engine APP_DATABASE_URL gallopify_app — policies apply in full verified JWT claims injected per session Neon Postgres RLS policies installed (ENABLE, deliberately not FORCE)
The activation gate is one Doppler secret: setting APP_DATABASE_URL moves RLS-scoped request sessions onto the restricted role. Unsetting it is the rollback.

How the switch works

A table owner bypasses RLS unconditionally, so the policies merged long ago have always been inert on the owner connection. The code is fully prepared:

What the restricted role may touch

GRANT USAGE ON SCHEMA public TO gallopify_app;
GRANT SELECT, INSERT, UPDATE         ON properties         TO gallopify_app;
GRANT SELECT                         ON investments        TO gallopify_app;
GRANT SELECT                         ON investments_catalog TO gallopify_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON upcoming_cashflows TO gallopify_app;
GRANT SELECT                         ON transactions       TO gallopify_app;
GRANT SELECT                         ON management_requests TO gallopify_app;

Deliberately not granted: the migration-version table, sequences (all PKs are generated UUIDs), default privileges (a new table gets zero restricted-role access until its own migration makes a deliberate grant decision — safe by default), and anything DDL-adjacent or role-escalating. notifications and offboarding_requests intentionally have no grants and no policies: they are reached only through backend-owned owner sessions whose services enforce recipient/initiator/approver checks — the safe-by-default posture working as designed.

Activation steps (per environment: dev → staging → production)

  1. Apply migrations normally (owner connection). This creates the role and grants; nothing changes yet because nothing connects as it. On Neon the migration tolerates the non-superuser owner.
  2. Set the role's password out-of-band against that environment's database, as the owner (e.g. Neon console SQL editor — never in a migration, never committed): ALTER ROLE gallopify_app PASSWORD '<freshly generated>'. (Neon assigns every role a control-plane password; this replaces it with one you control.)
  3. Build the connection string — same host/database as the owner URL, swapping only the credentials — and store it as a new Doppler secret in that environment's backend config, never reusing the owner secret's name.
  4. Set APP_DATABASE_URL in that Doppler config and redeploy/restart (settings are cached per process; a running process won't see the change). Raw owner traffic keeps using DATABASE_URL; only RLS-scoped request sessions move to the restricted role.

Prove it live, then know the rollback

  1. Verify it is live: hit the health endpoint, then exercise one real cross-org request with a real cross-org account and confirm it 404s or empty-lists rather than leaking rows — the end-to-end proof that enforcement is doing something. A quick psql double-check: SET ROLE gallopify_app; SELECT count(*) FROM properties; should return 0 with no claims set (fail closed).
  2. Rollback is unsetting APP_DATABASE_URL and redeploying — RLS sessions immediately fall back to the owner engine. Never roll back by downgrading the migration in a live environment; that would drop the role out from under live connection pools.

Do not treat the role's existence as activation. The 2026-09-07 verification found exactly this state: role created, grants correct, policies installed — and enforcement fully inert because steps 2–4 were never done. Activation is proven only by a restricted-role session filtering a real cross-org request.

Notes that keep this honest under Neon