poc: security barrier views via security levels, with property-checked ordering - #38568
Draft
jubrad wants to merge 29 commits into
Draft
poc: security barrier views via security levels, with property-checked ordering#38568jubrad wants to merge 29 commits into
jubrad wants to merge 29 commits into
Conversation
A view is currently not a boundary the optimizer respects. A predicate
supplied by a reader crosses into the view's own plan, propagates to the
base collection, and is scheduled ahead of the view's own filter, because
`MapFilterProject` orders predicates by the column position they first
reference. Since error messages embed the offending value, a reader with
`SELECT` on only the view can aim a fallible expression at rows the view
excludes and read the hidden values back out of the error.
Adopt PostgreSQL's model: mark the view, then decline to dissolve its
boundary.
CREATE VIEW my_orders WITH (SECURITY BARRIER) AS
SELECT * FROM orders WHERE tenant = current_user;
Two gates implement it. `inline_views` skips a barrier, so it stays a
distinct object referenced through a global `Get`, which every per-object
transform already treats as opaque, and no `Let` binding is formed for
`push_into_let_binding` to push into. `optimize_dataflow_filters_inner`
then applies only leakproof predicates to a barrier; a blocked predicate
never enters the view's plan, so it also never propagates onward to the
view's own inputs.
Leakproof is `!could_error()`. Materialize has no user-defined functions,
so every function in a predicate is a builtin whose only value-dependent
channel is its error, and `could_error` is already fail-safe: `true` by
default for a `LazyUnaryFunc`, otherwise derived from whether the Rust
signature returns a `Result`. This is a stronger footing than
`pg_proc.proleakproof`, which is a superuser assertion. It also keeps the
cost low, since `=`, `<`, `>`, and `AND` are all infallible and still
cross the barrier to reach persist pruning and index lookups.
The barrier set is optimizer-only state, so it lives on `TransformCtx`
rather than on `DataflowDescription`, which is part of the compute
protocol. `TransformCtx::global` takes it as a required argument so that
a caller which forgets it fails to compile rather than silently losing
the barrier.
Tests: `src/transform/tests/test_security_barrier.rs` asserts the blocked,
admitted, and unprotected cases at the `optimize_dataflow_filters_inner`
seam, including one test that pins the current exposure so a regression is
visible. `test/sqllogictest/security_barrier.slt` covers the SQL surface
and the resulting `EXPLAIN` plans.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Materialize's RBAC documentation states that `SELECT` on a view is what governs access to it, which invites using a view as a row-level access control boundary. The privilege half of that pattern works. The optimizer half does not: a reader's predicate crosses the view boundary and is evaluated against rows the view excludes, and error messages embed the offending value, so the pattern is exfiltratable. Record the problem, the exposure, and a proposal that adopts PostgreSQL's pre-9.5 model of marking the view and declining to dissolve its boundary, rather than PostgreSQL's later per-qual `security_level` machinery, which exists to serve row-level security that Materialize does not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not for merge, and does not compile. This is a measurement: change `MirRelationExpr::Filter`'s `predicates` from `Vec<MirScalarExpr>` to `Vec<Predicate>`, where `Predicate` carries the expression plus the security level it was introduced at, then count what the compiler objects to. Result, with `Deref`/`DerefMut` to `MirScalarExpr`, a blanket `From<MirScalarExpr>`, and `MirRelationExpr::filter` generalized to take anything convertible: mz-expr 18 errors, 11 after Deref, resolved here mz-expr-parser 1 error mz-transform 26 errors across 11 files downstream 4 direct `Filter` sites in compute-types and adapter Roughly 40 sites in 14 files, all mechanical. That is well below the earlier estimate of "22 files in mz-transform, each of which could silently produce an insecure plan", which was made by counting files that mention `Filter` rather than by measuring. Two findings matter more than the count. The ergonomics that shrink the diff are the same thing that makes level propagation unsafe. `Deref` and a blanket `From` mean most code compiles untouched, but every implicit conversion assigns level 0. Worse, a dozen of the errors are of the form `expected Vec<MirScalarExpr>, found Vec<Predicate>`, where the obvious fix maps away the level and the compiler is then satisfied. The type change enumerates the sites; it does not enforce anything at them. The sidecar encoding fixes the defect that killed the wrapper spike on `security-barrier-views-spike`. Carrying the level beside the expression leaves the expression itself untouched, so a temporal predicate stays syntactically recognizable and `MfpPlan` can still lower it. The wrapper could not do that, because wrapping is what made it opaque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An alternative to the object-level gates in the parent commit, for the
owning team to compare against. Carries a security level on each
predicate and constrains movement and evaluation order by level, rather
than refusing to inline a barrier view.
`MirRelationExpr::Filter` now holds `Vec<Predicate>`, where `Predicate`
is an expression plus the level it was introduced at. `inline_views`
raises the consumer's non-leakproof predicates a level before splicing a
barrier in, so the view's body is optimized jointly with its consumer's
while ordering is still constrained.
Three seams enforce it:
- Movement. `PredicatePushdown` splits levelled predicates into a
`Filter` it does not touch, so they cannot sink below a lower level nor
be reported at a `Get`, where the level would be lost. Everything below
that split sees only level-0 predicates, which is what makes it sound
for the rest of the transform to keep working in bare expressions.
- Derivation. An equivalence class is seeded only from predicates that
are leakproof or unconstrained, since a derived qual carries no level.
- Canonicalization. `canonicalize_leveled_predicates` canonicalizes each
level in isolation, because splitting, cross-reducing, and deduplicating
across levels each launder a level away.
What works: the workspace compiles, and the change is inert on plans with
no barrier. Every text plan in the EXPLAIN corpus is byte-identical. The
JSON plans move, because the serialized MIR now nests `{expr, level}`,
which would also mean an expression-cache format bump.
What does not work, and is the reason this is not a finished POC: a query
against a barrier view fails to plan. Ordering has to be enforced inside a
single `MapFilterProject`, because LIR has no `Filter` operator and
lowering asserts every filter was extracted into an MFP. Two ways to carry
the level into the MFP were tried and both failed:
- Encoding it in the predicate's position is unsound. `memoize_expressions`
indexes `expressions` by position, so a position past the end panics.
- Declining to fuse across levels leaves a `Filter` that cannot lower.
So `MapFilterProject` has to carry the level in `predicates`, which reaches
LIR, `MfpPlan`, and the compute protocol. That is roughly 48 further sites
and contradicts the earlier expectation that the concept could stop at the
MIR boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes the levels mechanism work end to end. A barrier view is now inlined
like any other, and ordering rather than the object boundary carries the
guarantee.
`MapFilterProject` carries the level, which is what the previous commit was
missing. Its predicate entry becomes `(usize, Predicate<E>)`, reusing the
same `Predicate` the mid-level plan uses rather than introducing a second
notion, and `Predicate` moves to its own module as the owner of the concept.
`level` is declared before `expr` so the derived `Ord` puts lower levels
first, and one `sort_predicates` is the single place evaluation order is
decided, used by both `filter` and `optimize`.
Ordering is enforced in the sort, so nothing below it needs the concept:
`SafeMfpPlan` already evaluates predicates in vector order and
short-circuits, and lowering carries the level across only because
`MapFilterProject` is one generic type shared by both plan levels.
Per review, there is no blanket `From<MirScalarExpr>`. Construction names
the level: `Predicate::unconstrained` for a predicate written against its
own collection, `Predicate::at_level` otherwise. `filter` keeps taking bare
expressions and means "unconstrained"; `filter_leveled` re-applies existing
predicates. That split is what forced each of the roughly thirty conversion
sites to state which it meant, and it is how three real level-dropping bugs
were found: `into_map_filter_project` returned bare expressions, and both
`literal_constraints` and `canonicalize_mfp` rebuilt an MFP from them.
Levels appear in `EXPLAIN` when non-zero, so a barrier is visible in a plan
and testable.
Verified:
- 1768 sqllogictest assertions pass across the EXPLAIN corpus, privileges,
joins, subqueries, and the barrier tests.
- No text plan in the EXPLAIN corpus changes. Only the JSON goldens move,
for the serialization shape, which implies an expression-cache bump.
- The barrier case is pinned by physical plans over a table whose column
order would otherwise schedule the reader's cast first:
plain filter=((text_to_integer(#0{secret}) > 0) AND (#1{tenant} = "alice"))
barrier filter=((#1{tenant} = "alice") AND (text_to_integer(#0{secret}) > 0))
Both are fully inlined, so the ordering is the only difference.
- A leakproof predicate stays at level 0 and still reaches the source import.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Borrows two layers from the RBAC formal methods design in MaterializeInc#38186 and applies them to the levels mechanism. Both target its standing cost, which is that a future transform can drop a level and nothing reports it. Layer 0b, make the failure mode a type error. `Predicate::level` becomes private. It can be read and it can be raised, and there is no operation that lowers it: `raise` is the only assignment to the field anywhere in the tree. Losing a constraint now requires constructing a fresh predicate through `Predicate::unconstrained`, which is a named call a reviewer can grep for, rather than an assignment that reads like nothing. This is the same move as the `Authorized<Plan>` typestate that design proposes for the authorization chokepoint, and it earns the same thing: monotonicity by construction rather than by review. Layer 1, an executable specification with property tests. `src/expr/tests/test_security_levels.rs` states the ordering requirement once, declaratively, as `is_admissible`, written from the intended semantics rather than derived from `sort_predicates`, so the two are independent and can disagree. Five properties are checked over arbitrary predicate lists: adding predicates yields an admissible order; sorting neither invents, drops, nor relabels; `optimize` re-establishes the order after rebuilding the list; `optimize` never invents a level; and level outranks position, which is the point of the whole exercise. The properties were verified red before green. Removing the level from the sort key fails three of the five. Layer 0a is already present: levels render in `EXPLAIN` when non-zero, and `security_barrier.slt` is the golden that makes a policy change show up as a diff in the PR that causes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 29, 2026
Adds the plan a barrier makes more expensive, which is the measurement the document was missing, and the reasoning it supports. The cost is an arrangement over a join input that can no longer be pre-filtered, it is inherent to the guarantee rather than to either mechanism, and it is concentrated rather than average, which is what makes attribution the deciding argument. Also records the counter-argument, that the failure modes are asymmetric, and the detection idea that answers it without paying the cost everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison claimed nothing but leakproof predicates crosses, and described the two plans as planned separately, which reads as isolation. Neither is right: they are two objects in one dataflow, the transform pipeline runs per object so no transform sees both, and column demand and monotonicity cross ungated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The residual risk was stated as every future transform having to preserve the level, which is too broad twice over. A new transform cannot miss that levels exist, because constructing a predicate requires naming one and the field cannot be lowered, and reordering within a plan is self-healing because the operator sort re-establishes the order. What is left is relocation across operators, derivation from a levelled predicate, and a new place a predicate can live. The first is closable as a plan-tree invariant in Typecheck, the second is the one that stays soft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section opened by attributing an imprecise statement of the risk to nobody in particular. This is the first document to describe the mechanism, so there is no prior statement of it to correct. States the contract directly instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The taxonomy of what a future transform must get right is implementation guidance and now lives on the mz_expr::predicate module, where a transform author will encounter it. The design doc keeps one paragraph per mechanism, which is what a reader choosing between them needs, and records that mechanism A has a residual risk too rather than implying only B does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves the rules a transform has to follow out of the design doc and onto mz_expr::predicate, where somebody writing a transform will encounter them. Two things the types already prevent, one thing the operator sort makes self-healing, and three shapes that need care: relocating a levelled predicate across operators, deriving one from a levelled predicate, and adding a new home for predicates. The relocation rule is stated as a plan invariant with a TODO to assert it in typecheck, which already validates plan invariants between transforms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removed sections on residual risk and minimal viable prototypes for mechanisms A and B from the security barrier views documentation.
The open question linked a section that had been renamed and then removed; it now points at B (risk). The comparison referred to a table row by a number that two deleted rows had invalidated, so it names the row instead. Also restores a short statement of mechanism A's residual risk. With only B's stated, the doc read as though A carried none, where in fact A's guarantee rests on an architectural property that nothing states and no type enforces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strongest argument for opt-in is not the query cost but what default-on would do to a version upgrade. Plans are re-derived on every build version, which is safe only because the new version normally lands on the same plan and therefore the same memory requirement. Zero-downtime upgrade runs both generations until the new one has re-hydrated, so the headroom that would absorb a larger plan is already spent. A replica that cannot fit the new plan never finishes re-hydrating and the upgrade never cuts over. Co-Authored-By: Claude Opus 5 <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.
Motivation
Self-contained: this branch carries the design doc, the mechanism, and the
verification, so the whole story reads in one place. It supersedes #38566, which
is the same mechanism without the design doc merged in. #38562 implements the
same SQL surface by a different mechanism and is the thing to compare against.
The exposure. Our RBAC documentation says
SELECTon a view is what governsaccess to it, which invites using a view as a row-level access control boundary.
The privilege half of that works. The optimizer half does not: a reader's
predicate crosses the view boundary and is evaluated against rows the view
excludes, and error messages embed the offending value, so
SELECT * FROM my_orders WHERE ssn::int > 0exfiltrates another tenant'sssnin one query. Materializing is not a workaround, because an index only applies
on the cluster holding it and a
current_userview cannot be indexed at all.Description
CREATE VIEW ... WITH (SECURITY BARRIER). A barrier view is inlined like anyother, so its body is still optimized jointly with the reader's query, and an
ordering constraint rather than an object boundary carries the guarantee.
Each predicate carries the security level it was introduced at.
inline_viewsraises the consumer's non-leakproof predicates a level before splicing a barrier
in; raising rather than assigning is what makes nested barriers compose. Three
seams enforce it: movement, where a levelled predicate may not sink below a
lower level nor be reported at a
Get; derivation, where an equivalenceclass is seeded only from leakproof or unconstrained predicates; and
ordering, where
MapFilterProjectsorts by level first.MapFilterProjectreuses the samePredicatetype the mid-level plan usesrather than introducing a second notion.
levelis declared beforeexprso thederived
Ordputs lower levels first, and onesort_predicatesis the singleplace evaluation order is decided.
Leakproof means infallible. We have no user-defined functions, so a builtin's
only value-dependent channel is the error it raises, and
could_erroralreadyanswers that with a fail-safe, type-derived default.
=,<,>, andANDcome out leakproof automatically, which is why the measured cost is nil.
Verification
Borrowing the ladder from #38186, whose Layers 0 and 1 apply almost unchanged.
Layer 0b, the failure mode is a type error.
Predicate::levelis private. Itcan be read and it can be raised, and there is no operation that lowers it:
raiseis the only assignment to that field in the tree. Losing a constraintrequires constructing a fresh predicate through
Predicate::unconstrained, anamed call a reviewer can grep for, rather than an assignment that reads like
nothing. Same move as the
Authorized<Plan>typestate that design proposes, andit earns the same thing: monotonicity by construction rather than by review.
This is not decoration. There is no blanket
From<MirScalarExpr>, so every oneof ~30 conversion sites had to state whether it was creating a new unconstrained
predicate or re-applying an existing one. That is how three real level-dropping
bugs surfaced, in
into_map_filter_project,literal_constraints, andcanonicalize_mfp. A blanket conversion would have compiled all three silently,and one of them made the mechanism look like it worked while levels were quietly
zero.
Layer 1, an executable specification.
src/expr/tests/test_security_levels.rsstates the requirement once,declaratively, as
is_admissible, written from the intended semantics ratherthan derived from
sort_predicates, so the two are independent and can disagree.Five properties over arbitrary predicate lists: sorting yields an admissible
order; it neither invents, drops, nor relabels;
optimizere-establishes theorder after rebuilding the list;
optimizenever invents a level; and leveloutranks position. Verified red before green — removing the level from the
sort key fails three of the five.
Layer 0a is already present: levels render in
EXPLAINwhen non-zero, andsecurity_barrier.sltis the golden that makes a change show up as a diff in thePR that causes it.
Layer 2 (Kani on
sort_predicates) is a reasonable follow-up and is not donehere. Layer 3 does not apply; the property is not temporal.
What remains unverified, in that design's Class C sense: that every fallible
predicate crossing a barrier gets raised. That is a property of
raise_security_levelcovering every place a predicate can live, and it is thedirect analogue of P9, the keystone lemma held up by convention.
Results
joins, subqueries, and the barrier tests.
bin/fmtand clippy clean.for the serialization shape, which implies an expression-cache format bump.
would otherwise schedule the reader's cast first:
Both are fully inlined, so ordering is the only difference. A leakproof
predicate stays at level 0 and still reaches the source import.
Costs
Predicateis 104 bytes againstMirScalarExpr's 96. The level reaches LIR andthe compute protocol, though the runtime does not need it:
SafeMfpPlanalreadyevaluates in vector order, so the sort discharges the constraint. It crosses
because
MapFilterProjectis one generic type shared by both plan levels.Unlike #38562, this mechanism generalizes to row-level security, which needs
exactly this per-predicate ordering and has no object boundary to hang a gate on.
🤖 Generated with Claude Code