feat(security): per-user row level security on self-service tables (BUNYIP-344) #363
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/BUNYIP-344-rls-self-service-slice"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Phase 1 of BUNYIP-344: a DB-level per-user row level security backstop on the strictly self-service tables, so a user cannot read another user's rows even with a crafted query. Mirrors the Mokosh tenant RLS pattern (
mokosh-servermigrations 024/038), substitutinguser_id/app.current_user_idfortenant_id/app.current_tenant.Why this scope (the blocker)
The 2026-07-10 audit flagged three gaps (table enumeration, the Mokosh pattern, the context mechanism). Resolving them surfaced a fourth: bunyip is the identity provider, and its dominant table access is pre-auth, cross-user, or admin, with no current-user context. Login reads
usersby email before any user is known; refresh, Stripe webhooks, OIDC issuance, and admin all run without a per-user GUC. A blanket per-user RLS keyed oncurrent_user_idwould fail-close authentication. So this slice applies RLS only to the tables a logged-in user reads/writes about only themselves, and leavesusers, the token tables, and subscriptions on application-levelWHERE user_idfiltering. Full enumeration and rationale are in the BUNYIP-344 ticket comment.Changes
20260713000010enables + forces row level security and attaches a fail-closeduser_isolationpolicy (USING + WITH CHECK onuser_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid) totrusted_devices,user_totp,recovery_codes,application_entitlements,email_change_requests. Runtime no-op until a NOBYPASSRLS role is provisioned, so safe to land now (same posture as Mokosh 038).db::begin_with_usersets the per-user GUC transaction-locally; an optionalAPP_DATABASE_URLbuilds a second pool for the NOBYPASSRLS role and falls back to the primary pool when unset.begin_with_useras the reference pattern; the two repo reads were widened to accept anyPgExecutor(backward compatible), and the existingWHERE user_idfilters are kept as defence in depth..env.exampledocumentsAPP_DATABASE_URLand the one-time role-provisioning deploy step.Verification
just check-containergreen (fmt + clippy -D warnings + 102 tests).user_isolationpolicy, and the new env-gated regression test (bunyip-api/tests/rls_isolation.rs) proves that under an explicit NOBYPASSRLS role user A cannot read or write user B's rows even with a crafted no-WHERE query. The test skips whenRLS_TEST_DATABASE_URLis unset, so the no-Postgres CI stays green.Follow-ups (not in this PR)
bunyip_appNOBYPASSRLS role and setAPP_DATABASE_URLin dev-sso / prod (cluster-global deploy step).begin_with_userincrementally.🤖 Generated with Claude Code