Skip to content

feat(safegres): L11/L12 — materialized-view snapshots and non-barrier filtering views - #1614

Merged
pyramation merged 1 commit into
mainfrom
feat/view-exposure-boundaries
Aug 2, 2026
Merged

feat(safegres): L11/L12 — materialized-view snapshots and non-barrier filtering views#1614
pyramation merged 1 commit into
mainfrom
feat/view-exposure-boundaries

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Gaps 5 and 7 from the constructive-db view review, as one PR: the two remaining ways a readable view is not what its definition says. Both are reach edges in the existing RoleReach model with proof: 'ast', both ship info / score-neutral, and neither recommends a revoke.

L11 — a materialized view is a snapshot, not a query. Its rows were computed once by whoever ran REFRESH and are then served verbatim: the base relations are never consulted at read time, so their ACLs do not apply and their RLS policies do not run. A matview can carry neither policies (RLS attaches to tables) nor security_invoker (a view-only reloption) — pinned by a new assertion in view-introspect.test.ts — so there is no option on the object that reinstates the filter. Fires on both shapes:

!hasSelect                → the role holds nothing on the base relation
hasSelect && subjectToRls → it holds a grant, but the stored rows skipped the policies

The second is the one catalog-only analysis is worst at: the ACL says the role may read the table, RLS says it may read three rows of it, and the matview hands it all of them.

L12 — a view's WHERE is a filter, not a boundary. Without security_barrier the planner may push the caller's qual below the view's own, so a leaky operator or a COST 0.0001 function is evaluated against the rows the view was written to hide. Verified on PG 18 with a NOTICE-raising function in the caller's predicate: the non-barrier view emitted a notice for the hidden row, the security_barrier = true view did not.

L12 is deliberately narrow — it needs all four of:

definer view  ∧  ¬security_barrier  ∧  body has WHERE/HAVING  ∧  role has no direct SELECT on the base

A caller that can read the base table directly loses nothing to a pushed-down qual, and row-limiting through a join or a LIMIT is not how a boundary gets written, so only an explicit WHERE/HAVING counts. The overlap with L8 is intentional: L8 says the table is reachable, L12 says even the rows the view hides are — and the remedies differ (security_invoker vs. security_barrier).

Conservatism

bodyFiltersRows returns boolean | null, and null (unparseable) suppresses — a body we cannot read is "unknown", never "does not filter". Same for an opaque body on either rule. Neither hint mentions revoking; both say so explicitly, and the corpus asserts it.

Shape of the change

  • ViewSnapshot.securityBarrier, read the same way security_invoker is (pg_options_to_table(...)::boolean, so on/1/yes are handled).
  • RoleReachEdge gains { kind: 'matview' }, emitted for the outermost hop when ViewReachInput.materialized — the rows are stored, so the edge holds whatever the reader's privileges are.
  • The nested-view walk in definer-view.ts is extracted verbatim into exported readBodies / resolveViewBases; L8's behavior is unchanged and its tests are untouched apart from the new snapshot field. A matview is deliberately kept out of the relation index (it is terminal for anything reading through it) while its own body is still read.
  • src/commands/audit.ts / src/index.ts: additive only — a dispatch block, a needsViews disjunct, and three exports.

Severity

info is the new-rule posture, not the verdict. L11 on an RLS-protected base is high on its own merits — a SELECT grant on the matview is an unconditional grant on every row a refresh captured. L12 is the weakest of the L-series and is honestly medium: it needs a leaky operator or a cheap function in the caller's qual, so it is a capability rather than an unconditional read.

Verification

pnpm build / pnpm lint (2 pre-existing warnings) / pnpm test → 34 suites, 417 tests. Corpus cases 31–34, two of them negatives (32 a matview that changes nothing about reach, 34 case 33 after its fix). A full constructive-db audit with this build produces zero L11/L12 findings and an unchanged score (100 A+ / perf 91.2 A) — no matviews today, and every generated view is security_invoker.

Link to Devin session: https://app.devin.ai/sessions/f340c08768814b278a179aea7994f924
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 2, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit 3214a70 into main Aug 2, 2026
16 checks passed
@pyramation
pyramation deleted the feat/view-exposure-boundaries branch August 2, 2026 20:02
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.

1 participant