feat(security): per-user row level security on self-service tables (BUNYIP-344) #363

Merged
longjacksonle merged 1 commit from feat/BUNYIP-344-rls-self-service-slice into main 2026-07-13 18:36:31 +02:00

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-server migrations 024/038), substituting user_id / app.current_user_id for tenant_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 users by 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 on current_user_id would fail-close authentication. So this slice applies RLS only to the tables a logged-in user reads/writes about only themselves, and leaves users, the token tables, and subscriptions on application-level WHERE user_id filtering. Full enumeration and rationale are in the BUNYIP-344 ticket comment.

Changes

  • Migration 20260713000010 enables + forces row level security and attaches a fail-closed user_isolation policy (USING + WITH CHECK on user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid) to trusted_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_user sets the per-user GUC transaction-locally; an optional APP_DATABASE_URL builds a second pool for the NOBYPASSRLS role and falls back to the primary pool when unset.
  • Trusted-devices list handler rerouted through begin_with_user as the reference pattern; the two repo reads were widened to accept any PgExecutor (backward compatible), and the existing WHERE user_id filters are kept as defence in depth.
  • .env.example documents APP_DATABASE_URL and the one-time role-provisioning deploy step.

Verification

  • just check-container green (fmt + clippy -D warnings + 102 tests).
  • Verified end to end against a real Postgres: the migration applies, all five tables carry the enabled + forced user_isolation policy, 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 when RLS_TEST_DATABASE_URL is unset, so the no-Postgres CI stays green.

Follow-ups (not in this PR)

  • Provision the bunyip_app NOBYPASSRLS role and set APP_DATABASE_URL in dev-sso / prod (cluster-global deploy step).
  • Route the remaining self-service reads (entitlements, TOTP status, recovery codes, email-change) through begin_with_user incrementally.
  • Narrow the ticket AC to "self-service user-owned tables".

🤖 Generated with Claude Code

## 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-server` migrations 024/038), substituting `user_id` / `app.current_user_id` for `tenant_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 `users` by 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 on `current_user_id` would fail-close authentication. So this slice applies RLS only to the tables a logged-in user reads/writes about only themselves, and leaves `users`, the token tables, and subscriptions on application-level `WHERE user_id` filtering. Full enumeration and rationale are in the BUNYIP-344 ticket comment. ## Changes - **Migration `20260713000010`** enables + forces row level security and attaches a fail-closed `user_isolation` policy (USING + WITH CHECK on `user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid`) to `trusted_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_user`** sets the per-user GUC transaction-locally; an optional `APP_DATABASE_URL` builds a second pool for the NOBYPASSRLS role and falls back to the primary pool when unset. - **Trusted-devices list handler** rerouted through `begin_with_user` as the reference pattern; the two repo reads were widened to accept any `PgExecutor` (backward compatible), and the existing `WHERE user_id` filters are kept as defence in depth. - **`.env.example`** documents `APP_DATABASE_URL` and the one-time role-provisioning deploy step. ## Verification - `just check-container` green (fmt + clippy -D warnings + 102 tests). - Verified end to end against a real Postgres: the migration applies, all five tables carry the enabled + forced `user_isolation` policy, 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 when `RLS_TEST_DATABASE_URL` is unset, so the no-Postgres CI stays green. ## Follow-ups (not in this PR) - Provision the `bunyip_app` NOBYPASSRLS role and set `APP_DATABASE_URL` in dev-sso / prod (cluster-global deploy step). - Route the remaining self-service reads (entitlements, TOTP status, recovery codes, email-change) through `begin_with_user` incrementally. - Narrow the ticket AC to "self-service user-owned tables". 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(security): per-user row level security on self-service tables (BUNYIP-344)
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 59s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt + clippy + build + tests (pull_request) Successful in 1h6m23s
75b0d8fdc6
Add a DB-level per-user isolation backstop on the strictly self-service tables (trusted_devices, user_totp, recovery_codes, application_entitlements, email_change_requests), mirroring the Mokosh tenant RLS pattern (mokosh-server migrations 024/038) with user_id/app.current_user_id in place of tenant_id/app.current_tenant.

Migration 20260713000010 enables + forces row level security and attaches a fail-closed user_isolation policy keyed on the transaction-local GUC app.current_user_id (USING + WITH CHECK), so an unset GUC returns zero rows and rejects writes. It is a runtime no-op until a NOBYPASSRLS role is provisioned, so it is safe to land now, exactly as Mokosh 038 was.

Deliberately scoped to self-service tables only. bunyip is the identity provider: login reads users by email pre-auth, and refresh, Stripe webhooks, OIDC issuance, and admin all operate with no current-user context. Applying per-user RLS to users, the token tables, or subscriptions would fail-close authentication, so those keep relying on application-level WHERE user_id filters. See the BUNYIP-344 ticket comment for the full enumeration and rationale.

Mechanism: an optional APP_DATABASE_URL builds a second pool (falls back to the primary pool when unset) and db::begin_with_user sets the per-user GUC transaction-locally. The trusted-devices list handler is rerouted through it as the reference pattern (repo reads widened to accept any PgExecutor, backward compatible; existing WHERE filters kept as defence in depth). Remaining self-service reads can move onto the same helper incrementally.

Verified end to end against a real Postgres: the migration applies, all five tables carry the enabled+forced user_isolation policy, and the new env-gated regression test (bunyip-api/tests/rls_isolation.rs, skips when RLS_TEST_DATABASE_URL is unset so the no-Postgres CI stays green) proves that under a NOBYPASSRLS role user A cannot read or write user B's rows even with a crafted no-WHERE query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle deleted branch feat/BUNYIP-344-rls-self-service-slice 2026-07-13 18:36:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!363
No description provided.