feat(security): self-provision the bunyip_app NOBYPASSRLS role (BUNYIP-360) #365

Merged
longjacksonle merged 1 commit from feat/BUNYIP-360-provision-app-role into main 2026-07-13 21:14:51 +02:00

What

Activates the per-user RLS shipped in BUNYIP-344 (#363/#364) by provisioning the unprivileged bunyip_app role automatically, so the user_isolation policies stop being a runtime no-op. Mirrors the Mokosh self-provisioning posture (mokosh-server/src/db/provision.rs), scoped to bunyip's single extra role.

How

  • db::provision_app_role idempotently creates bunyip_app (NOSUPERUSER NOBYPASSRLS) over the primary pool, which already connects as the DB owner/superuser and can CREATE ROLE, so no separate admin connection is needed. It runs AFTER migrations (so GRANT ... ON ALL TABLES covers everything they created) plus ALTER DEFAULT PRIVILEGES for future migrations. Every statement is idempotent, so it is safe to run each boot.
  • main.rs calls it when BUNYIP_APP_PASSWORD is set, then builds the self-service app pool as that role (APP_DATABASE_URL). Unset keeps the previous no-op fallback to the primary pool.
  • Wiring: compose.dev.yml defaults both values off POSTGRES_PASSWORD, so a fresh just dev activates RLS with no extra config (dev-sso inherits it). compose.yml adds the bunyip_app_password + app_database_url file secrets (empty files keep RLS inactive). .env.example rewrites the old manual-provisioning note to describe the automatic path and the password-must-match constraint.

Verification

  • just check-container green (fmt + clippy -D warnings + 104 tests).
  • Against a real Postgres, the isolation test now provisions the real bunyip_app role, connects a pool AS it (a genuine NOBYPASSRLS login), and asserts it is fail-closed with no GUC (zero rows) and correctly scoped through begin_with_user (only the acting user's row). Confirmed on the live DB that bunyip_app is NOSUPERUSER + NOBYPASSRLS + LOGIN with the expected table grants. The DB-backed test skips when RLS_TEST_DATABASE_URL is unset, so the no-Postgres CI stays green.

Deploy notes

  • dev / dev-sso: nothing to do; just dev (fresh volume) provisions and activates RLS.
  • prod: create ./secrets/bunyip_app_password and ./secrets/app_database_url (the latter = postgres://bunyip_app:<same-password>@postgres:5432/bunyip). Leaving both empty keeps RLS inactive. On boot the api logs bunyip_app RLS role provisioned then Self-service RLS pool established.
  • The primary DATABASE_URL role is unchanged (still the bypassing owner that runs migrations and system paths).

Closes BUNYIP-360.

🤖 Generated with Claude Code

## What Activates the per-user RLS shipped in BUNYIP-344 (#363/#364) by provisioning the unprivileged `bunyip_app` role automatically, so the `user_isolation` policies stop being a runtime no-op. Mirrors the Mokosh self-provisioning posture (`mokosh-server/src/db/provision.rs`), scoped to bunyip's single extra role. ## How - `db::provision_app_role` idempotently creates `bunyip_app` (NOSUPERUSER NOBYPASSRLS) over the primary pool, which already connects as the DB owner/superuser and can `CREATE ROLE`, so no separate admin connection is needed. It runs AFTER migrations (so `GRANT ... ON ALL TABLES` covers everything they created) plus `ALTER DEFAULT PRIVILEGES` for future migrations. Every statement is idempotent, so it is safe to run each boot. - `main.rs` calls it when `BUNYIP_APP_PASSWORD` is set, then builds the self-service app pool as that role (`APP_DATABASE_URL`). Unset keeps the previous no-op fallback to the primary pool. - Wiring: `compose.dev.yml` defaults both values off `POSTGRES_PASSWORD`, so a fresh `just dev` activates RLS with no extra config (dev-sso inherits it). `compose.yml` adds the `bunyip_app_password` + `app_database_url` file secrets (empty files keep RLS inactive). `.env.example` rewrites the old manual-provisioning note to describe the automatic path and the password-must-match constraint. ## Verification - `just check-container` green (fmt + clippy -D warnings + 104 tests). - Against a real Postgres, the isolation test now provisions the real `bunyip_app` role, connects a pool AS it (a genuine NOBYPASSRLS login), and asserts it is fail-closed with no GUC (zero rows) and correctly scoped through `begin_with_user` (only the acting user's row). Confirmed on the live DB that `bunyip_app` is `NOSUPERUSER + NOBYPASSRLS + LOGIN` with the expected table grants. The DB-backed test skips when `RLS_TEST_DATABASE_URL` is unset, so the no-Postgres CI stays green. ## Deploy notes - dev / dev-sso: nothing to do; `just dev` (fresh volume) provisions and activates RLS. - prod: create `./secrets/bunyip_app_password` and `./secrets/app_database_url` (the latter = `postgres://bunyip_app:<same-password>@postgres:5432/bunyip`). Leaving both empty keeps RLS inactive. On boot the api logs `bunyip_app RLS role provisioned` then `Self-service RLS pool established`. - The primary `DATABASE_URL` role is unchanged (still the bypassing owner that runs migrations and system paths). Closes BUNYIP-360. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(security): self-provision the bunyip_app NOBYPASSRLS role to activate per-user RLS (BUNYIP-360)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 50s
Check / fmt + clippy + build + tests (pull_request) Failing after 37m37s
Create release / Create release from merged PR (pull_request) Has been skipped
2625fe8825
Activate the per-user RLS from BUNYIP-344 by provisioning the unprivileged bunyip_app role automatically at startup, mirroring the Mokosh self-provisioning posture (mokosh-server/src/db/provision.rs) scoped to bunyip's single extra role.

db::provision_app_role idempotently creates bunyip_app (NOSUPERUSER NOBYPASSRLS) over the primary pool, which already connects as the DB owner/superuser and can CREATE ROLE, so no separate admin connection is needed. It runs AFTER migrations so GRANT ... ON ALL TABLES covers every table they created, plus ALTER DEFAULT PRIVILEGES for future migrations; every statement is idempotent, so it is safe to run each boot. main.rs calls it when BUNYIP_APP_PASSWORD is set, then builds the self-service app pool as that role (APP_DATABASE_URL). Unset keeps the previous no-op fallback to the primary pool.

Wiring: compose.dev.yml defaults BUNYIP_APP_PASSWORD and APP_DATABASE_URL off POSTGRES_PASSWORD, so a fresh just dev activates RLS with no extra config; dev-sso inherits it. compose.yml adds the bunyip_app_password and app_database_url file secrets (empty files keep RLS inactive). .env.example rewrites the old manual-provisioning note to describe the automatic path and the password-must-match constraint.

Verified against a real Postgres: the isolation test now provisions the real bunyip_app role, connects a pool AS it (a genuine NOBYPASSRLS login), and asserts it is fail-closed with no GUC (zero rows) and correctly scoped through begin_with_user (only the acting user's row). Confirmed on the live DB that bunyip_app is NOSUPERUSER + NOBYPASSRLS + LOGIN with the expected table grants. Full just check-container green (fmt + clippy -D warnings + 104 tests); the DB-backed test skips when RLS_TEST_DATABASE_URL is unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-13 21:14:35 +02:00
longjacksonle deleted branch feat/BUNYIP-360-provision-app-role 2026-07-13 21:14:52 +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!365
No description provided.