feat(rls): flip tenant_isolation fail-closed with WITH CHECK and FORCE RLS #188
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/pms-257-rls-fail-closed"
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?
Migration 038 rewrites the
tenant_isolationRLS policy on every tenant_id table. The 024 policy was fail-open (USING (tenant_id = COALESCE(NULLIF(current_setting('app.current_tenant', true), '')::uuid, tenant_id))), so an unset GUC matched every row, and it was USING-only so it never constrained INSERT/UPDATE. The new policy usesUSING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)plus the same expression asWITH CHECK, so an unset or empty GUC matches no rows (read) and rejects the write, and a write cannot set or move a row into another tenant. Each table also getsFORCE ROW LEVEL SECURITYso the owner is not exempt.The loop mirrors 024's information_schema selection, so it also covers tables created after 024 (027/028/031/032/...) that never received the original policy.
DROP POLICY IF EXISTSmakes it idempotent and lets it replace the 024 policy in place.FORCE RLS does not apply to superusers or BYPASSRLS roles, so the migration / owner role keeps operating unrestricted while the application role must run without BYPASSRLS for the policy to bite. Roles are cluster-global and environment-specific, so the migration documents this posture rather than creating or altering roles; pointing the app connection at an unprivileged role is a deployment step gated on migrating the remaining read paths onto
begin_with_tenant(parent PMS-255). Until then the app still connects as the bypassing role and relies on explicitWHERE tenant_id = $1filters, so this flip is a no-op for the running app and safe to land now.tests/rls_isolation.rs proves the behaviour against an explicitly NOBYPASSRLS role via
SET ROLE: an unset GUC exposes zero rows, the matching GUC exposes exactly the tenant's row, a write with the wrong GUC is rejected (42501), and a matching write passes.#PMS-257
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Migration 038 rewrites the `tenant_isolation` RLS policy on every tenant_id table. The 024 policy was fail-open (`USING (tenant_id = COALESCE(NULLIF(current_setting('app.current_tenant', true), '')::uuid, tenant_id))`), so an unset GUC matched every row, and it was USING-only so it never constrained INSERT/UPDATE. The new policy uses `USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)` plus the same expression as `WITH CHECK`, so an unset or empty GUC matches no rows (read) and rejects the write, and a write cannot set or move a row into another tenant. Each table also gets `FORCE ROW LEVEL SECURITY` so the owner is not exempt. The loop mirrors 024's information_schema selection, so it also covers tables created after 024 (027/028/031/032/...) that never received the original policy. `DROP POLICY IF EXISTS` makes it idempotent and lets it replace the 024 policy in place. FORCE RLS does not apply to superusers or BYPASSRLS roles, so the migration / owner role keeps operating unrestricted while the application role must run without BYPASSRLS for the policy to bite. Roles are cluster-global and environment-specific, so the migration documents this posture rather than creating or altering roles; pointing the app connection at an unprivileged role is a deployment step gated on migrating the remaining read paths onto `begin_with_tenant` (parent PMS-255). Until then the app still connects as the bypassing role and relies on explicit `WHERE tenant_id = $1` filters, so this flip is a no-op for the running app and safe to land now. tests/rls_isolation.rs proves the behaviour against an explicitly NOBYPASSRLS role via `SET ROLE`: an unset GUC exposes zero rows, the matching GUC exposes exactly the tenant's row, a write with the wrong GUC is rejected (42501), and a matching write passes. #PMS-257 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>