Skip to content

poc: security barrier views via security levels, with property-checked ordering - #38568

Draft
jubrad wants to merge 29 commits into
MaterializeInc:mainfrom
jubrad:security-levels-verified
Draft

poc: security barrier views via security levels, with property-checked ordering#38568
jubrad wants to merge 29 commits into
MaterializeInc:mainfrom
jubrad:security-levels-verified

Conversation

@jubrad

@jubrad jubrad commented Aug 29, 2026

Copy link
Copy Markdown
Member

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 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 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 > 0 exfiltrates another tenant's ssn
in one query. Materializing is not a workaround, because an index only applies
on the cluster holding it and a current_user view cannot be indexed at all.

Description

CREATE VIEW ... WITH (SECURITY BARRIER). A barrier view is inlined like any
other, 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_views
raises 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 equivalence
class is seeded only from leakproof or unconstrained predicates; and
ordering, where MapFilterProject sorts by level first.

MapFilterProject reuses the same Predicate type the mid-level plan uses
rather than introducing a second notion. 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.

Leakproof means infallible. We have no user-defined functions, so a builtin's
only value-dependent channel is the error it raises, and could_error already
answers that with a fail-safe, type-derived default. =, <, >, and AND
come 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::level is private. It
can be read and it can be raised, and there is no operation that lowers it:
raise is the only assignment to that field in the tree. Losing a constraint
requires constructing a fresh predicate through Predicate::unconstrained, a
named call a reviewer can grep for, rather than an assignment that reads like
nothing. Same move as the Authorized<Plan> typestate that design proposes, and
it earns the same thing: monotonicity by construction rather than by review.

This is not decoration. There is no blanket From<MirScalarExpr>, so every one
of ~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, and
canonicalize_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.rs states the 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 over arbitrary predicate lists: sorting yields an admissible
order; it neither invents, drops, nor relabels; optimize re-establishes the
order after rebuilding the list; optimize never invents a level; and level
outranks 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 EXPLAIN when non-zero, and
security_barrier.slt is the golden that makes a change show up as a diff in the
PR that causes it.

Layer 2 (Kani on sort_predicates) is a reasonable follow-up and is not done
here. 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_level covering every place a predicate can live, and it is the
direct analogue of P9, the keystone lemma held up by convention.

Results

  • 1768 sqllogictest assertions pass across the EXPLAIN corpus, privileges,
    joins, subqueries, and the barrier tests. bin/fmt and clippy clean.
  • No text plan in the EXPLAIN corpus changes. Only the JSON goldens move,
    for the serialization shape, which implies an expression-cache format bump.
  • The guarantee 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 ordering is the only difference. A leakproof
predicate stays at level 0 and still reaches the source import.

Costs

Predicate is 104 bytes against MirScalarExpr's 96. The level reaches LIR and
the compute protocol, though the runtime does not need it: SafeMfpPlan already
evaluates in vector order, so the sort discharges the constraint. It crosses
because MapFilterProject is 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

jubrad and others added 12 commits August 28, 2026 20:11
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>
jubrad and others added 15 commits August 31, 2026 08:52
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>
jubrad and others added 2 commits August 31, 2026 11:00
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>
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