Skip to content

sql: add security barrier views POC - #38562

Draft
jubrad wants to merge 1 commit into
MaterializeInc:mainfrom
jubrad:security-barrier-views
Draft

sql: add security barrier views POC#38562
jubrad wants to merge 1 commit into
MaterializeInc:mainfrom
jubrad:security-barrier-views

Conversation

@jubrad

@jubrad jubrad commented Aug 29, 2026

Copy link
Copy Markdown
Member

Motivation

Design doc: #38561. Alternative mechanism, same SQL surface: #38566.

Design doc: #38561. That PR carries the full problem statement, the
alternatives, and the open questions. This PR is the prototype it refers to.

In short: a view is not a boundary the optimizer respects. A reader's
predicate crosses into the view's own plan, propagates to the base collection,
and is scheduled ahead of the view's own filter. Because our error messages
embed the offending value, a reader holding SELECT on only the view can aim
a fallible expression at rows the view excludes and read the hidden values out
of the resulting error.

Description

Adds an opt-in view option:

CREATE VIEW my_orders WITH (SECURITY BARRIER) AS
    SELECT * FROM orders WHERE tenant = current_user;

Most of the diff is option plumbing (lexer, AST, parser, planner, catalog).
The optimizer change proper is two gates and one predicate in
src/transform/src/dataflow.rs:

  • inline_views skips a barrier view, so it stays a distinct
    objects_to_build entry referenced through a global Get. Every per-object
    transform already treats that as opaque, and no Let binding is formed for
    PredicatePushdown::push_into_let_binding to push into.
  • optimize_dataflow_filters_inner 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, which is what keeps it away from
    persist filter pushdown.

No other cross-object pass needs a gate. optimize_dataflow_demand prunes
columns rather than rows, which is safe and which PostgreSQL also permits.

Three decisions a reviewer may want to push on:

  • Leakproof is !could_error(). We have 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 does promote could_error from an optimizer hint to a security
    boundary, so a wrong could_error = false becomes a vulnerability rather than
    a performance bug. Flagged as an open question on the design PR.
  • The barrier set lives on TransformCtx, not DataflowDescription. It is
    optimizer-only state, and DataflowDescription is part of the compute
    protocol.
  • TransformCtx::global takes it as a required argument rather than a
    builder method, so a caller that forgets it fails to compile instead of
    silently losing the barrier. That is why the diff touches seven call sites.

Behavior is unchanged for views without the option: both gates are no-ops on an
empty barrier set.

Not included, and called out in the design doc: an mz_views column reporting
the flag, ALTER VIEW ... SET (SECURITY BARRIER), and user-facing docs. This
is a prototype to validate the design, not a shippable feature.

Verification

src/transform/tests/test_security_barrier.rs asserts three behaviors at the
optimize_dataflow_filters_inner seam: a fallible predicate is blocked by a
barrier, a leakproof one still crosses, and the unprotected case still leaks.
That last test pins the current exposure so a regression in the gate is visible
rather than silent.

test/sqllogictest/security_barrier.slt covers the SQL surface, the
SHOW CREATE VIEW round trip, and the resulting EXPLAIN plans. Unprotected:

Source materialize.public.orders
  filter=((#0{tenant} = "alice") AND ((#2{amount} / (char_length(#1{secret}) - 3)) > 0))

With the barrier, the division stays above the view and only the tenant filter
reaches the base collection:

Explained Query:
  Filter ((#2{amount} / (char_length(#1{secret}) - 3)) > 0)
    ReadGlobalFromSameDataflow materialize.public.barrier_orders

materialize.public.barrier_orders:
  Filter (#0{tenant} = "alice")
    ReadStorage materialize.public.orders

Source materialize.public.orders
  filter=((#0{tenant} = "alice"))

The existing EXPLAIN and privilege suites are unchanged, confirming the
default path is untouched.

Note: code comments reference the design doc path, so #38561 should land first.

🤖 Generated with Claude Code

Reading order: #38568 is the self-contained package (design + mechanism B + formal verification). #38562 is mechanism A. This PR is one part of that set.

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>
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