fix(#321): least-privilege DB role for the .NET backend β RLS + #281 trigger as a live backstop#325
Merged
Conversation
β¦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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The .NET messaging backend connected to Postgres as the
postgressuperuser, which bypasses RLS (superuser + table owner) and self-exempts the #281 column-guard trigger (enforce_message_update_columnsearly-returns whenauth.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_approle 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_approle (monolithic migration, idempotentDO $$ pg_rolesguard):LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS, non-owner,GRANT authenticatedonly (NOTservice_role, so it cannotSET ROLEinto a BYPASSRLS role). Because the messaging RLS policies are role-agnostic and gate onauth.uid(), this role gets exactly the participant scoping the C# checks enforce. Password is per-env (roles.sqllocally; injected in prod β never committed).RlsActorMiddleware(new; wired afterUseAuthorization): per authenticated request, opens one transaction and runsSET LOCAL ROLE authenticated+set_config('request.jwt.claims', β¦, true)(parameterized) soauth.uid()resolves in-DB.SET LOCALis transaction-scoped β the actor never leaks onto the next request drawing the same pooled connection (Npgsql pools; multiplexing off). The request-scopedAppDbContextis shared, so every controller's EF/raw-SQL enlists automatically β no controller changes, and each request is now atomic.docker-compose.yml+appsettings.jsonswitchUsernamepostgresβdotnet_app.Proof (the whole point)
New
tests/rls/dotnet-least-privilege.test.tsβ a rawpgconnection ASdotnet_appreplicating 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?":42501, row unchanged42501Combined 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 createsdotnet_app) βpnpm test:rls= 113/113:.dotnetconformance stays 12/12 under the least-privilege role (legitimate access unbroken)The postgres superuser no longer touches the messaging tables. This PR self-validates: it edits paths in the
Conformancegate's filter, so that gate runs the fulltest:rlsagainst a fresh dual-provider stack.Follow-ups (out of scope)
dotnet_apppassword in prod + extend the fix(#265): .NET messaging conformance 12/12 + JWT hardening + C11 doc-truthΒ #322 fail-closed startup guard to also refuse the demo DB password in non-Development.FORCE ROW LEVEL SECURITYisn't needed (non-owner role); broadening conformance coverage (groups/archive/realtime, C15βC28) is the separate next Migration anchor: swap ScriptHammer's Supabase backend for ASP.NET Core + EF Core (.NET), KDG as referenceΒ #265 increment.Refs #321, #280, #281.
π€ Generated with Claude Code