Skip to content

fix(#321): least-privilege DB role for the .NET backend β€” RLS + #281 trigger as a live backstop#325

Merged
TortoiseWolfe merged 1 commit into
mainfrom
fix/321-least-privilege-dotnet-role
Jul 18, 2026
Merged

fix(#321): least-privilege DB role for the .NET backend β€” RLS + #281 trigger as a live backstop#325
TortoiseWolfe merged 1 commit into
mainfrom
fix/321-least-privilege-dotnet-role

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

What & why

The .NET messaging backend connected to Postgres as the postgres superuser, which bypasses RLS (superuser + table owner) and self-exempts the #281 column-guard trigger (enforce_message_update_columns early-returns when auth.uid() IS NULL AND role IN (…,'postgres')). So the C# controller checks were the sole authorization guard β€” one missed check is a data-exposure/integrity bug with nothing behind it. This closes the last Phase-1 security item on the owner-blocking #280 arc (verify-and-wire landed in #322/#324).

The backend now connects as a least-privilege dotnet_app role and sets the RLS actor per request (the PostgREST model), so RLS + the trigger enforce as a live backstop behind the C# checks β€” belt-and-suspenders.

How

  • dotnet_app role (monolithic migration, idempotent DO $$ pg_roles guard): LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS, non-owner, GRANT authenticated only (NOT service_role, so it cannot SET ROLE into a BYPASSRLS role). Because the messaging RLS policies are role-agnostic and gate on auth.uid(), this role gets exactly the participant scoping the C# checks enforce. Password is per-env (roles.sql locally; injected in prod β€” never committed).
  • RlsActorMiddleware (new; wired after UseAuthorization): per authenticated request, opens one transaction and runs SET LOCAL ROLE authenticated + set_config('request.jwt.claims', …, true) (parameterized) so auth.uid() resolves in-DB. SET LOCAL is transaction-scoped β†’ the actor never leaks onto the next request drawing the same pooled connection (Npgsql pools; multiplexing off). The request-scoped AppDbContext is shared, so every controller's EF/raw-SQL enlists automatically β€” no controller changes, and each request is now atomic.
  • Connection: docker-compose.yml + appsettings.json switch Username postgres β†’ dotnet_app.
  • Comments: corrected the stale "the trigger exempts this backend" notes (it now fires).

Proof (the whole point)

New tests/rls/dotnet-least-privilege.test.ts β€” a raw pg connection AS dotnet_app replicating the middleware's exact seam (SET LOCAL ROLE + claims) and issuing the cross-user write with no C# involved β€” i.e. "what if a C# check were bypassed?":

  • BLOCKS a recipient rewriting the sender's ciphertext β†’ 42501, row unchanged
  • BLOCKS a recipient soft-deleting the sender's message β†’ 42501
  • BLOCKS an outsider reading / updating the conversation β†’ 0 rows (RLS row-scoping)
  • ALLOWS the recipient marking read + the sender editing their own recent message

Combined with the conformance suite proving the middleware sets the actor correctly (if it didn't, auth.uid() would be null and RLS would deny every participant read, failing conformance), this proves the backstop end-to-end β€” without temporarily removing a production security check.

Verified live

docker compose --profile supabase --profile dotnet up (fresh DB β†’ the migration creates dotnet_app) β†’ pnpm test:rls = 113/113:

  • .dotnet conformance stays 12/12 under the least-privilege role (legitimate access unbroken)
  • the 6 backstop cases green
  • all 9 other RLS suites green

The postgres superuser no longer touches the messaging tables. This PR self-validates: it edits paths in the Conformance gate's filter, so that gate runs the full test:rls against a fresh dual-provider stack.

Follow-ups (out of scope)

Refs #321, #280, #281.

πŸ€– Generated with Claude Code

…trigger as a live backstop

The .NET messaging backend connected as the postgres superuser, which bypasses
RLS AND self-exempts the #281 column-guard trigger (it early-returns when
auth.uid() IS NULL and role='postgres'). So the C# controller checks were the
SOLE authorization guard β€” one missed check = a data-exposure/integrity bug with
nothing behind it. Last Phase-1 security item on the owner-blocking #280 arc.

The backend now connects as a least-privilege dotnet_app role and sets the RLS
actor per request (the PostgREST model), so RLS + the trigger enforce as a live
backstop behind the C# checks.

- Migration: create dotnet_app (LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS,
  non-owner), GRANT authenticated ONLY (not service_role, so it cannot escalate
  past RLS). Idempotent DO $$ pg_roles guard; password set per-env (roles.sql
  locally, injected in prod β€” never committed here).
- RlsActorMiddleware: per authenticated request, one transaction +
  `SET LOCAL ROLE authenticated` + set_config('request.jwt.claims', …) so
  auth.uid() resolves in-DB. SET LOCAL is transaction-scoped β†’ no identity leak
  across Npgsql-pooled connections. The scoped DbContext is shared, so every
  controller's EF/raw-SQL enlists automatically (each request is atomic too).
- Connection: docker-compose + appsettings switch Username postgres β†’ dotnet_app.
- Backstop test (tests/rls/dotnet-least-privilege.test.ts): a raw pg connection
  AS dotnet_app replicating the middleware seam proves the DB blocks cross-user
  writes (recipient rewriting ciphertext β†’ 42501; outsider reads β†’ 0 rows) even
  with the C# bypassed, and allows the legitimate flows.
- conformance.yml: expose SUPABASE_DB_HOST/PORT so the backstop test reaches raw
  Postgres on the runner.

Verified live: full pnpm test:rls 113/113 β€” the .dotnet conformance stays 12/12
under the least-privilege role (legitimate access unbroken; the middleware must
be setting the actor correctly, else RLS would deny every participant read) + the
6 backstop cases green. The postgres superuser no longer touches messaging.

Follow-up (out of scope): rotate/inject a distinct dotnet_app password in prod +
extend the #322 startup guard to refuse the demo DB password in non-Development.

Refs #321, #280, #281.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit 7c4a999 into main Jul 18, 2026
19 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the fix/321-least-privilege-dotnet-role branch July 18, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants