From 6e1ea6f3e95ce91bffc7e867d759a531656f625f Mon Sep 17 00:00:00 2001 From: jubrad Date: Fri, 28 Aug 2026 20:11:10 -0500 Subject: [PATCH] sql: add security barrier views 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 --- src/adapter/src/catalog/migrate.rs | 1 + src/adapter/src/catalog/state.rs | 1 + .../src/coord/sequencer/inner/create_view.rs | 2 + src/adapter/src/optimize/copy_to.rs | 1 + src/adapter/src/optimize/dataflows.rs | 16 ++ src/adapter/src/optimize/index.rs | 1 + src/adapter/src/optimize/materialized_view.rs | 1 + src/adapter/src/optimize/metric_sink.rs | 1 + src/adapter/src/optimize/peek.rs | 1 + src/adapter/src/optimize/subscribe.rs | 1 + src/catalog/src/memory/objects.rs | 8 + src/clusterd-test-driver/src/dataflow.rs | 4 + src/mz-deploy/src/cli/commands/test/lower.rs | 1 + src/sql-lexer/src/keywords.txt | 1 + src/sql-parser/src/ast/defs/ddl.rs | 34 ++++ src/sql-parser/src/ast/defs/statement.rs | 9 +- src/sql-parser/src/parser.rs | 19 ++- src/sql-parser/tests/testdata/ddl | 42 ++++- src/sql-parser/tests/testdata/explain | 4 +- src/sql-parser/tests/testdata/id | 4 +- src/sql-parser/tests/testdata/select | 2 +- src/sql-pretty/src/doc.rs | 7 + src/sql/src/normalize.rs | 1 + src/sql/src/plan.rs | 3 + src/sql/src/plan/statement/ddl.rs | 10 +- src/sqllogictest/src/runner.rs | 1 + src/transform/src/dataflow.rs | 61 ++++++- src/transform/src/lib.rs | 16 +- src/transform/tests/test_security_barrier.rs | 153 ++++++++++++++++++ test/sqllogictest/security_barrier.slt | 93 +++++++++++ 30 files changed, 477 insertions(+), 22 deletions(-) create mode 100644 src/transform/tests/test_security_barrier.rs create mode 100644 test/sqllogictest/security_barrier.slt diff --git a/src/adapter/src/catalog/migrate.rs b/src/adapter/src/catalog/migrate.rs index e5b0014066c55..ef6335b96bae3 100644 --- a/src/adapter/src/catalog/migrate.rs +++ b/src/adapter/src/catalog/migrate.rs @@ -470,6 +470,7 @@ fn rewrite_sources_to_tables( definition: ViewDefinition { name: progress_name, columns: vec![], + with_options: vec![], query: Query { ctes: CteBlock::Simple(vec![]), body: SetExpr::Table(RawItemName::Id( diff --git a/src/adapter/src/catalog/state.rs b/src/adapter/src/catalog/state.rs index 38901e8f754da..87ac42d79961c 100644 --- a/src/adapter/src/catalog/state.rs +++ b/src/adapter/src/catalog/state.rs @@ -1575,6 +1575,7 @@ impl CatalogState { conn_id: None, resolved_ids, dependencies: DependencyIds(dependencies), + security_barrier: view.security_barrier, }) } Plan::CreateMaterializedView(CreateMaterializedViewPlan { diff --git a/src/adapter/src/coord/sequencer/inner/create_view.rs b/src/adapter/src/coord/sequencer/inner/create_view.rs index 6d9348412178f..4c1136617cd2c 100644 --- a/src/adapter/src/coord/sequencer/inner/create_view.rs +++ b/src/adapter/src/coord/sequencer/inner/create_view.rs @@ -391,6 +391,7 @@ impl Coordinator { dependencies, column_names, temporary, + security_barrier, }, drop_ids, if_not_exists, @@ -425,6 +426,7 @@ impl Coordinator { }, resolved_ids: resolved_ids.clone(), dependencies: dependencies.clone(), + security_barrier, }), owner_id: *session.current_role_id(), }, diff --git a/src/adapter/src/optimize/copy_to.rs b/src/adapter/src/optimize/copy_to.rs index 9130f0f86937e..1c221fdeac229 100644 --- a/src/adapter/src/optimize/copy_to.rs +++ b/src/adapter/src/optimize/copy_to.rs @@ -334,6 +334,7 @@ impl<'s> Optimize>> for Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Run global optimization. mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?; diff --git a/src/adapter/src/optimize/dataflows.rs b/src/adapter/src/optimize/dataflows.rs index f06c50362e517..2b72b9c720deb 100644 --- a/src/adapter/src/optimize/dataflows.rs +++ b/src/adapter/src/optimize/dataflows.rs @@ -128,6 +128,13 @@ pub struct DataflowBuilder<'a> { pub replan: Option, /// A guard for recursive operations in this [`DataflowBuilder`] instance. recursion_guard: RecursionGuard, + /// Views imported so far that are declared security barriers. + /// + /// Collected during import because that is the only point at which the + /// optimizer has the catalog entry in hand. Handed to + /// [`mz_transform::TransformCtx::global`], which is what the barrier gates + /// read. See `doc/developer/design/20260828_security_barrier_views.md`. + security_barriers: BTreeSet, } /// Behavior to prepare relation and scalar expressions for use in a dataflow. @@ -288,9 +295,15 @@ impl<'a> DataflowBuilder<'a> { compute, replan: None, recursion_guard: RecursionGuard::with_limit(RECURSION_LIMIT), + security_barriers: BTreeSet::new(), } } + /// The security barriers among the views imported into the dataflow so far. + pub fn security_barriers(&self) -> &BTreeSet { + &self.security_barriers + } + // TODO(aalexandrov): strictly speaking it should be better if we can make // `config: &OptimizerConfig` a field in the enclosing builder. However, // before we can do that we should make sure that nobody outside of the @@ -353,6 +366,9 @@ impl<'a> DataflowBuilder<'a> { dataflow.import_source(*id, source.desc.typ().clone(), monotonic); } CatalogItem::View(view) => { + if view.security_barrier { + self.security_barriers.insert(*id); + } let expr = view.locally_optimized_expr.as_ref(); self.import_view_into_dataflow(id, expr, dataflow, features)?; } diff --git a/src/adapter/src/optimize/index.rs b/src/adapter/src/optimize/index.rs index 22b3b634ce3e7..2efc590bb8602 100644 --- a/src/adapter/src/optimize/index.rs +++ b/src/adapter/src/optimize/index.rs @@ -179,6 +179,7 @@ impl Optimize for Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Run global optimization. mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?; diff --git a/src/adapter/src/optimize/materialized_view.rs b/src/adapter/src/optimize/materialized_view.rs index 971c0b52c395d..6d46ac3054e2e 100644 --- a/src/adapter/src/optimize/materialized_view.rs +++ b/src/adapter/src/optimize/materialized_view.rs @@ -277,6 +277,7 @@ impl Optimize for Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Run global optimization. mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?; diff --git a/src/adapter/src/optimize/metric_sink.rs b/src/adapter/src/optimize/metric_sink.rs index 7baa79e90ead9..a7f815f3a8409 100644 --- a/src/adapter/src/optimize/metric_sink.rs +++ b/src/adapter/src/optimize/metric_sink.rs @@ -215,6 +215,7 @@ impl Optimize for Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Run global optimization. mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?; diff --git a/src/adapter/src/optimize/peek.rs b/src/adapter/src/optimize/peek.rs index f7d2b2cff4c24..ccc4edd77df4b 100644 --- a/src/adapter/src/optimize/peek.rs +++ b/src/adapter/src/optimize/peek.rs @@ -330,6 +330,7 @@ impl<'s> Optimize>> for Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Let's already try creating a fast path plan. If successful, we don't need to run the diff --git a/src/adapter/src/optimize/subscribe.rs b/src/adapter/src/optimize/subscribe.rs index b16e4263743e2..42ba3fe8c6cf1 100644 --- a/src/adapter/src/optimize/subscribe.rs +++ b/src/adapter/src/optimize/subscribe.rs @@ -214,6 +214,7 @@ impl Optimizer { &self.typecheck_ctx, &mut df_meta, Some(&mut self.metrics), + df_builder.security_barriers(), ); // Run global optimization. mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?; diff --git a/src/catalog/src/memory/objects.rs b/src/catalog/src/memory/objects.rs index e6fd520c3a8a2..2550d3e95af20 100644 --- a/src/catalog/src/memory/objects.rs +++ b/src/catalog/src/memory/objects.rs @@ -1401,6 +1401,14 @@ pub struct View { pub resolved_ids: ResolvedIds, /// All of the catalog objects that are referenced by this view. pub dependencies: DependencyIds, + /// Whether this view is a security barrier. + /// + /// A barrier view is never inlined into a reading dataflow, and only + /// leakproof predicates from a reader are pushed into its plan, so no + /// reader-supplied expression is evaluated against a row the view's own + /// filters would have excluded. See + /// `doc/developer/design/20260828_security_barrier_views.md`. + pub security_barrier: bool, } impl View { diff --git a/src/clusterd-test-driver/src/dataflow.rs b/src/clusterd-test-driver/src/dataflow.rs index b0218ad7fe8ff..848a2fe3f7250 100644 --- a/src/clusterd-test-driver/src/dataflow.rs +++ b/src/clusterd-test-driver/src/dataflow.rs @@ -508,6 +508,9 @@ impl DataflowBuilder { let indexes = ImportedIndexOracle::new(&mir.index_imports); let typecheck_ctx = empty_typechecking_context(); let mut df_meta = DataflowMetainfo::default(); + // The driver builds dataflows from a MIR spec with no catalog + // behind it, so no import can be a security barrier. + let security_barriers = std::collections::BTreeSet::new(); let mut ctx = TransformCtx::global( &indexes, &EmptyStatisticsOracle, @@ -515,6 +518,7 @@ impl DataflowBuilder { &typecheck_ctx, &mut df_meta, None, + &security_barriers, ); optimize_dataflow(&mut mir, &mut ctx, false) .map_err(|e| anyhow::anyhow!("optimizing dataflow failed: {e}"))?; diff --git a/src/mz-deploy/src/cli/commands/test/lower.rs b/src/mz-deploy/src/cli/commands/test/lower.rs index de04664624d52..35df921f6fcaf 100644 --- a/src/mz-deploy/src/cli/commands/test/lower.rs +++ b/src/mz-deploy/src/cli/commands/test/lower.rs @@ -800,6 +800,7 @@ fn create_target_view_sql(stmt: &Statement, fqn: &FullyQualifiedName) -> Result< definition: ViewDefinition { name: mv.name, columns: mv.columns, + with_options: vec![], query: mv.query, }, }, diff --git a/src/sql-lexer/src/keywords.txt b/src/sql-lexer/src/keywords.txt index ef62195f04d1e..3df8d2219b8d9 100644 --- a/src/sql-lexer/src/keywords.txt +++ b/src/sql-lexer/src/keywords.txt @@ -60,6 +60,7 @@ Auto Availability Avro Aws +Barrier Batch Begin Between diff --git a/src/sql-parser/src/ast/defs/ddl.rs b/src/sql-parser/src/ast/defs/ddl.rs index 5a591458609b7..bdde5decd85d0 100644 --- a/src/sql-parser/src/ast/defs/ddl.rs +++ b/src/sql-parser/src/ast/defs/ddl.rs @@ -28,6 +28,40 @@ use crate::ast::{ AstInfo, ColumnName, Expr, Ident, OrderByExpr, UnresolvedItemName, Version, WithOptionValue, }; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ViewOptionName { + /// The `SECURITY BARRIER [=] ` option. + SecurityBarrier, +} + +impl AstDisplay for ViewOptionName { + fn fmt(&self, f: &mut AstFormatter) { + match self { + ViewOptionName::SecurityBarrier => f.write_str("SECURITY BARRIER"), + } + } +} + +impl WithOptionName for ViewOptionName { + /// # WARNING + /// + /// Whenever implementing this trait consider very carefully whether or not + /// this value could contain sensitive user data. If you're uncertain, err + /// on the conservative side and return `true`. + fn redact_value(&self) -> bool { + match self { + ViewOptionName::SecurityBarrier => false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ViewOption { + pub name: ViewOptionName, + pub value: Option>, +} +impl_display_for_with_option!(ViewOption); + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum MaterializedViewOptionName { /// The `ASSERT NOT NULL [=] ` option. diff --git a/src/sql-parser/src/ast/defs/statement.rs b/src/sql-parser/src/ast/defs/statement.rs index a49fc215be0ab..fdd5d2d146808 100644 --- a/src/sql-parser/src/ast/defs/statement.rs +++ b/src/sql-parser/src/ast/defs/statement.rs @@ -33,7 +33,7 @@ use crate::ast::{ Ident, IntervalValue, KeyConstraint, MaterializedViewOption, Query, SelectItem, SinkEnvelope, SourceEnvelope, SourceIncludeMetadata, SubscribeOutput, TableAlias, TableConstraint, TableWithJoins, UnresolvedDatabaseName, UnresolvedItemName, UnresolvedObjectName, - UnresolvedSchemaName, Value, + UnresolvedSchemaName, Value, ViewOption, }; /// A top-level statement (SELECT, INSERT, CREATE, etc.) @@ -1513,6 +1513,7 @@ pub struct ViewDefinition { /// View name pub name: UnresolvedItemName, pub columns: Vec, + pub with_options: Vec>, pub query: Query, } @@ -1526,6 +1527,12 @@ impl AstDisplay for ViewDefinition { f.write_str(")"); } + if !self.with_options.is_empty() { + f.write_str(" WITH ("); + f.write_node(&display::comma_separated(&self.with_options)); + f.write_str(")"); + } + f.write_str(" AS "); f.write_node(&self.query); } diff --git a/src/sql-parser/src/parser.rs b/src/sql-parser/src/parser.rs index d878a936530da..8a8ed9ae23ed4 100644 --- a/src/sql-parser/src/parser.rs +++ b/src/sql-parser/src/parser.rs @@ -4172,17 +4172,34 @@ impl<'a> Parser<'a> { // ANSI SQL and Postgres support RECURSIVE here, but we don't. let name = self.parse_item_name()?; let columns = self.parse_parenthesized_column_list(Optional)?; - // Postgres supports WITH options here, but we don't. + let with_options = if self.parse_keyword(WITH) { + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_view_option)?; + self.expect_token(&Token::RParen)?; + options + } else { + vec![] + }; self.expect_keyword(AS)?; let query = self.parse_query()?; // Optional `WITH [ CASCADED | LOCAL ] CHECK OPTION` is widely supported here. Ok(ViewDefinition { name, columns, + with_options, query, }) } + fn parse_view_option(&mut self) -> Result, ParserError> { + self.expect_keywords(&[SECURITY, BARRIER])?; + let value = self.parse_optional_option_value()?; + Ok(ViewOption { + name: ViewOptionName::SecurityBarrier, + value, + }) + } + fn parse_create_materialized_view(&mut self) -> Result, ParserError> { let mut if_exists = if self.parse_keyword(OR) { self.expect_keyword(REPLACE)?; diff --git a/src/sql-parser/tests/testdata/ddl b/src/sql-parser/tests/testdata/ddl index 7db725257479f..f74b004e9aad1 100644 --- a/src/sql-parser/tests/testdata/ddl +++ b/src/sql-parser/tests/testdata/ddl @@ -411,40 +411,68 @@ error: Expected NOT, found EXISTS CREATE SCHEMA IF EXISTS foo ^ +parse-statement +CREATE VIEW v WITH (SECURITY BARRIER) AS SELECT a FROM t +---- +CREATE VIEW v WITH (SECURITY BARRIER) AS SELECT a FROM t +=> +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [ViewOption { name: SecurityBarrier, value: None }], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("a")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("t")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) + +parse-statement +CREATE VIEW v WITH (SECURITY BARRIER = true) AS SELECT a FROM t +---- +CREATE VIEW v WITH (SECURITY BARRIER = true) AS SELECT a FROM t +=> +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [ViewOption { name: SecurityBarrier, value: Some(Value(Boolean(true))) }], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("a")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("t")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) + +parse-statement +CREATE VIEW v WITH (SECURITY BARRIER = false) AS SELECT a FROM t +---- +CREATE VIEW v WITH (SECURITY BARRIER = false) AS SELECT a FROM t +=> +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [ViewOption { name: SecurityBarrier, value: Some(Value(Boolean(false))) }], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("a")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("t")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) + +parse-statement +CREATE VIEW v WITH (NONSENSE) AS SELECT a FROM t +---- +error: Expected SECURITY, found identifier "nonsense" +CREATE VIEW v WITH (NONSENSE) AS SELECT a FROM t + ^ + parse-statement CREATE VIEW myschema.myview AS SELECT foo FROM bar ---- CREATE VIEW myschema.myview AS SELECT foo FROM bar => -CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("myschema"), Ident("myview")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("myschema"), Ident("myview")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE TEMPORARY VIEW myview AS SELECT foo FROM bar ---- CREATE TEMPORARY VIEW myview AS SELECT foo FROM bar => -CreateView(CreateViewStatement { if_exists: Error, temporary: true, definition: ViewDefinition { name: UnresolvedItemName([Ident("myview")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: true, definition: ViewDefinition { name: UnresolvedItemName([Ident("myview")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE TEMP VIEW myview AS SELECT foo FROM bar ---- CREATE TEMPORARY VIEW myview AS SELECT foo FROM bar => -CreateView(CreateViewStatement { if_exists: Error, temporary: true, definition: ViewDefinition { name: UnresolvedItemName([Ident("myview")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: true, definition: ViewDefinition { name: UnresolvedItemName([Ident("myview")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE OR REPLACE VIEW v AS SELECT 1 ---- CREATE OR REPLACE VIEW v AS SELECT 1 => -CreateView(CreateViewStatement { if_exists: Replace, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Replace, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE VIEW IF NOT EXISTS v AS SELECT 1 ---- CREATE VIEW IF NOT EXISTS v AS SELECT 1 => -CreateView(CreateViewStatement { if_exists: Skip, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Skip, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE OR REPLACE VIEW IF NOT EXISTS v AS SELECT 1 @@ -458,14 +486,14 @@ CREATE VIEW v (has, cols) AS SELECT 1, 2 ---- CREATE VIEW v (has, cols) AS SELECT 1, 2 => -CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [Ident("has"), Ident("cols")], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }, Expr { expr: Value(Number("2")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [Ident("has"), Ident("cols")], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: None }, Expr { expr: Value(Number("2")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE VIEW IF NOT EXISTS myschema.myview AS SELECT foo FROM bar ---- CREATE VIEW IF NOT EXISTS myschema.myview AS SELECT foo FROM bar => -CreateView(CreateViewStatement { if_exists: Skip, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("myschema"), Ident("myview")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Skip, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("myschema"), Ident("myview")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Identifier([Ident("foo")]), alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("bar")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE MATERIALIZED VIEW myschema.myview AS SELECT foo FROM bar diff --git a/src/sql-parser/tests/testdata/explain b/src/sql-parser/tests/testdata/explain index 17bc338d82b50..648f70f654aaf 100644 --- a/src/sql-parser/tests/testdata/explain +++ b/src/sql-parser/tests/testdata/explain @@ -213,14 +213,14 @@ EXPLAIN LOCALLY OPTIMIZED PLAN FOR CREATE VIEW mv AS SELECT 665 ---- EXPLAIN LOCALLY OPTIMIZED PLAN FOR CREATE VIEW mv AS SELECT 665 => -ExplainPlan(ExplainPlanStatement { stage: Some(LocalPlan), with_options: [], format: None, explainee: CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("mv")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("665")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }, false) }) +ExplainPlan(ExplainPlanStatement { stage: Some(LocalPlan), with_options: [], format: None, explainee: CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("mv")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("665")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }, false) }) parse-statement EXPLAIN LOCALLY OPTIMIZED PLAN FOR CREATE OR REPLACE VIEW mv AS SELECT 665 ---- EXPLAIN LOCALLY OPTIMIZED PLAN FOR CREATE OR REPLACE VIEW mv AS SELECT 665 => -ExplainPlan(ExplainPlanStatement { stage: Some(LocalPlan), with_options: [], format: None, explainee: CreateView(CreateViewStatement { if_exists: Replace, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("mv")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("665")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }, false) }) +ExplainPlan(ExplainPlanStatement { stage: Some(LocalPlan), with_options: [], format: None, explainee: CreateView(CreateViewStatement { if_exists: Replace, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("mv")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("665")), alias: None }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }, false) }) parse-statement EXPLAIN CREATE VIEW mv AS SELECT 665 diff --git a/src/sql-parser/tests/testdata/id b/src/sql-parser/tests/testdata/id index f73e3f55197d4..024d2d281031a 100644 --- a/src/sql-parser/tests/testdata/id +++ b/src/sql-parser/tests/testdata/id @@ -53,14 +53,14 @@ CREATE VIEW v1 AS SELECT * FROM [ u1 as materialize.public.t1 VERSION 5] ---- CREATE VIEW v1 AS SELECT * FROM [u1 AS materialize.public.t1 VERSION 5] => -CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v1")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Wildcard], from: [TableWithJoins { relation: Table { name: Id("u1", UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("t1")]), Some(Version(5))), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v1")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Wildcard], from: [TableWithJoins { relation: Table { name: Id("u1", UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("t1")]), Some(Version(5))), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE VIEW "materialize"."public"."v3" AS SELECT * FROM [u1 AS "materialize"."public"."t1" VERSION 3] ---- CREATE VIEW materialize.public.v3 AS SELECT * FROM [u1 AS materialize.public.t1 VERSION 3] => -CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("v3")]), columns: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Wildcard], from: [TableWithJoins { relation: Table { name: Id("u1", UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("t1")]), Some(Version(3))), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("v3")]), columns: [], with_options: [], query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Wildcard], from: [TableWithJoins { relation: Table { name: Id("u1", UnresolvedItemName([Ident("materialize"), Ident("public"), Ident("t1")]), Some(Version(3))), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement CREATE VIEW "materialize"."public"."v3" AS SELECT * FROM [u1 AS "materialize"."public"."t1" VERSION foobar] diff --git a/src/sql-parser/tests/testdata/select b/src/sql-parser/tests/testdata/select index 57a834a31760d..6824c52b20ebd 100644 --- a/src/sql-parser/tests/testdata/select +++ b/src/sql-parser/tests/testdata/select @@ -972,7 +972,7 @@ CREATE VIEW v AS ---- CREATE VIEW v AS WITH a AS (SELECT 1 AS foo), b AS (SELECT 2 AS bar) SELECT foo + bar FROM a, b => -CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], query: Query { ctes: Simple([Cte { alias: TableAlias { name: Ident("a"), columns: [], strict: false }, id: (), query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: Some(Ident("foo")) }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } }, Cte { alias: TableAlias { name: Ident("b"), columns: [], strict: false }, id: (), query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("2")), alias: Some(Ident("bar")) }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } }]), body: Select(Select { distinct: None, projection: [Expr { expr: Op { op: Op { namespace: None, op: "+" }, expr1: Identifier([Ident("foo")]), expr2: Some(Identifier([Ident("bar")])) }, alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("a")])), alias: None }, joins: [] }, TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("b")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) +CreateView(CreateViewStatement { if_exists: Error, temporary: false, definition: ViewDefinition { name: UnresolvedItemName([Ident("v")]), columns: [], with_options: [], query: Query { ctes: Simple([Cte { alias: TableAlias { name: Ident("a"), columns: [], strict: false }, id: (), query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("1")), alias: Some(Ident("foo")) }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } }, Cte { alias: TableAlias { name: Ident("b"), columns: [], strict: false }, id: (), query: Query { ctes: Simple([]), body: Select(Select { distinct: None, projection: [Expr { expr: Value(Number("2")), alias: Some(Ident("bar")) }], from: [], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } }]), body: Select(Select { distinct: None, projection: [Expr { expr: Op { op: Op { namespace: None, op: "+" }, expr1: Identifier([Ident("foo")]), expr2: Some(Identifier([Ident("bar")])) }, alias: None }], from: [TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("a")])), alias: None }, joins: [] }, TableWithJoins { relation: Table { name: Name(UnresolvedItemName([Ident("b")])), alias: None }, joins: [] }], selection: None, group_by: [], having: None, qualify: None, options: [] }), order_by: [], limit: None, offset: None } } }) parse-statement roundtrip WITH cte (col1, col2) AS (SELECT foo, bar FROM baz) SELECT * FROM cte diff --git a/src/sql-pretty/src/doc.rs b/src/sql-pretty/src/doc.rs index a8741930b7a65..52b031b89e57d 100644 --- a/src/sql-pretty/src/doc.rs +++ b/src/sql-pretty/src/doc.rs @@ -883,6 +883,13 @@ impl Pretty { ")", )); } + if !v.with_options.is_empty() { + docs.push(bracket( + "WITH (", + comma_separate(|o| self.doc_display_pass(o), &v.with_options), + ")", + )); + } docs.push(nest_title("AS", self.doc_query(&v.query))); RcDoc::intersperse(docs, Doc::line()).group() } diff --git a/src/sql/src/normalize.rs b/src/sql/src/normalize.rs index 3edfa486b4157..2531c072cd587 100644 --- a/src/sql/src/normalize.rs +++ b/src/sql/src/normalize.rs @@ -394,6 +394,7 @@ pub fn create_statement( name, query, columns: _, + with_options: _, }, }) => { *name = if *temporary { diff --git a/src/sql/src/plan.rs b/src/sql/src/plan.rs index 15355536ae858..a017067d84613 100644 --- a/src/sql/src/plan.rs +++ b/src/sql/src/plan.rs @@ -1958,6 +1958,9 @@ pub struct View { pub column_names: Vec, /// If this view is created in the temporary schema, e.g. `CREATE TEMPORARY ...`. pub temporary: bool, + /// Whether reader-supplied expressions may be evaluated below this view's + /// own filters. See `doc/developer/design/20260828_security_barrier_views.md`. + pub security_barrier: bool, } #[derive(Clone, Debug)] diff --git a/src/sql/src/plan/statement/ddl.rs b/src/sql/src/plan/statement/ddl.rs index e8dbf023512f3..cf7801fcc72b6 100644 --- a/src/sql/src/plan/statement/ddl.rs +++ b/src/sql/src/plan/statement/ddl.rs @@ -83,7 +83,7 @@ use mz_sql_parser::ast::{ SqlServerConfigOptionName, Statement, TableConstraint, TableFromSourceColumns, TableFromSourceOption, TableFromSourceOptionName, TableOption, TableOptionName, UnresolvedDatabaseName, UnresolvedItemName, UnresolvedObjectName, UnresolvedSchemaName, Value, - ViewDefinition, WithOptionValue, + ViewDefinition, ViewOption, ViewOptionName, WithOptionValue, }; use mz_sql_parser::ident; use mz_sql_parser::parser::StatementParseResult; @@ -2623,6 +2623,8 @@ pub fn describe_create_view( Ok(StatementDesc::new(None)) } +generate_extracted_config!(ViewOption, (SecurityBarrier, bool, Default(false))); + pub fn plan_view( scx: &StatementContext, def: &mut ViewDefinition, @@ -2640,9 +2642,14 @@ pub fn plan_view( let ViewDefinition { name, columns, + with_options, query, } = def; + let ViewOptionExtracted { + security_barrier, .. + } = with_options.clone().try_into()?; + let query::PlannedRootQuery { expr, mut desc, @@ -2692,6 +2699,7 @@ pub fn plan_view( dependencies, column_names: names, temporary, + security_barrier, }; Ok((name, view)) diff --git a/src/sqllogictest/src/runner.rs b/src/sqllogictest/src/runner.rs index f1f7648eb8612..76120e08f4e0f 100644 --- a/src/sqllogictest/src/runner.rs +++ b/src/sqllogictest/src/runner.rs @@ -2950,6 +2950,7 @@ fn generate_view_sql( definition: ViewDefinition { name: name.clone(), columns: columns.clone(), + with_options: vec![], query, }, }) diff --git a/src/transform/src/dataflow.rs b/src/transform/src/dataflow.rs index 92f7fe4b1fc4e..659cd48194b8e 100644 --- a/src/transform/src/dataflow.rs +++ b/src/transform/src/dataflow.rs @@ -19,7 +19,7 @@ use std::collections::{BTreeMap, BTreeSet}; use itertools::Itertools; use mz_compute_types::dataflows::{BuildDesc, DataflowDesc, DataflowDescription, IndexImport}; use mz_expr::{ - AccessStrategy, CollectionPlan, Id, JoinImplementation, LocalId, MapFilterProject, + AccessStrategy, CollectionPlan, Eval, Id, JoinImplementation, LocalId, MapFilterProject, MirRelationExpr, MirScalarExpr, RECURSION_LIMIT, }; use mz_ore::stack::{CheckedRecursion, RecursionGuard, RecursionLimitError}; @@ -50,7 +50,7 @@ pub fn optimize_dataflow( fast_path_optimizer: bool, ) -> Result<(), TransformError> { // Inline views that are used in only one other view. - inline_views(dataflow)?; + inline_views(dataflow, transform_ctx.security_barriers)?; if fast_path_optimizer { optimize_dataflow_relations( @@ -67,7 +67,7 @@ pub fn optimize_dataflow( transform_ctx, )?; - optimize_dataflow_filters(dataflow)?; + optimize_dataflow_filters(dataflow, transform_ctx.security_barriers)?; // TODO: when the linear operator contract ensures that propagated // predicates are always applied, projections and filters can be removed // from where they come from. Once projections and filters can be removed, @@ -114,12 +114,20 @@ pub fn optimize_dataflow( } /// Inline views used in one other view, and in no exported objects. +/// +/// Views in `security_barriers` are never inlined. Inlining rewrites a view +/// into a `Let` binding, and `PredicatePushdown` pushes into a `Let` value +/// while leaving a global `Get` opaque, so declining to inline is what keeps a +/// barrier's plan from merging with its consumer's. #[mz_ore::instrument( target = "optimizer", level = "debug", fields(path.segment = "inline_views") )] -fn inline_views(dataflow: &mut DataflowDesc) -> Result<(), TransformError> { +fn inline_views( + dataflow: &mut DataflowDesc, + security_barriers: &BTreeSet, +) -> Result<(), TransformError> { // We cannot inline anything whose `BuildDesc::id` appears in either the // `index_exports` or `sink_exports` of `dataflow`, because we lose our // ability to name it. @@ -135,6 +143,9 @@ fn inline_views(dataflow: &mut DataflowDesc) -> Result<(), TransformError> { for index in (0..dataflow.objects_to_build.len()).rev() { // Capture the name used by others to reference this view. let global_id = dataflow.objects_to_build[index].id; + if security_barriers.contains(&global_id) { + continue; + } // Determine if any exports directly reference this view. let mut occurs_in_export = false; for (_gid, sink_desc) in dataflow.sink_exports.iter() { @@ -351,7 +362,10 @@ where level = "debug", fields(path.segment ="filters") )] -fn optimize_dataflow_filters(dataflow: &mut DataflowDesc) -> Result<(), TransformError> { +fn optimize_dataflow_filters( + dataflow: &mut DataflowDesc, + security_barriers: &BTreeSet, +) -> Result<(), TransformError> { // Contains id -> predicates map, describing those predicates that // can (but need not) be applied to the collection named by `id`. let mut predicates = BTreeMap::>::new(); @@ -364,6 +378,7 @@ fn optimize_dataflow_filters(dataflow: &mut DataflowDesc) -> Result<(), Transfor .rev() .map(|build_desc| (Id::Global(build_desc.id), build_desc.plan.as_inner_mut())), &mut predicates, + security_barriers, )?; // Push predicate information into the SourceDesc. @@ -394,20 +409,36 @@ fn optimize_dataflow_filters(dataflow: &mut DataflowDesc) -> Result<(), Transfor /// Pushes filters down through views in `view_sequence` in order. /// +/// A view in `security_barriers` accepts only leakproof predicates from its +/// consumers. A non-leakproof predicate is left above the consumer's `Get`, +/// where it sees only rows the barrier already admitted. Because it never +/// enters the barrier's plan it also never propagates onward to the barrier's +/// own inputs, which is what keeps it away from persist filter pushdown. +/// /// This method is made public for the sake of testing. /// TODO: make this private once we allow multiple exports per dataflow. pub fn optimize_dataflow_filters_inner<'a, I>( view_iter: I, predicates: &mut BTreeMap>, + security_barriers: &BTreeSet, ) -> Result<(), TransformError> where I: Iterator, { let transform = crate::predicate_pushdown::PredicatePushdown::default(); for (id, view) in view_iter { + let is_barrier = match id { + Id::Global(gid) => security_barriers.contains(&gid), + Id::Local(_) => false, + }; if let Some(list) = predicates.get(&id).clone() { + let list = list + .iter() + .filter(|p| !is_barrier || is_leakproof(p)) + .cloned() + .collect::>(); if !list.is_empty() { - *view = view.take_dangerous().filter(list.iter().cloned()); + *view = view.take_dangerous().filter(list); } } transform.action(view, predicates)?; @@ -415,6 +446,24 @@ where Ok(()) } +/// Whether `predicate` may be evaluated against rows a security barrier would +/// have excluded. +/// +/// Materialize has no user-defined functions, so every function reachable from +/// a predicate is a builtin whose only value-dependent channel is the error it +/// raises: error messages embed the offending value, and an error anywhere in a +/// dataflow fails the whole query. Leakproof therefore reduces to infallible, +/// which [`Eval::could_error`] already answers. Its default is fail-safe, being +/// `true` for a `LazyUnaryFunc` that does not override it, and otherwise derived +/// from whether the function returns a `Result`. +/// +/// NOTE: this is a weaker claim than "reveals nothing". Evaluation time and +/// memory still vary with the hidden value, as they do in PostgreSQL, and no +/// barrier closes those channels. +fn is_leakproof(predicate: &mz_expr::MirScalarExpr) -> bool { + !predicate.could_error() +} + /// Propagates information about monotonic inputs through operators, /// using [`mz_repr::optimize::OptimizerFeatures`] from `ctx` for [`crate::analysis::Analysis`]. #[mz_ore::instrument( diff --git a/src/transform/src/lib.rs b/src/transform/src/lib.rs index 712913a035ce8..c4c858dad356f 100644 --- a/src/transform/src/lib.rs +++ b/src/transform/src/lib.rs @@ -21,7 +21,7 @@ #![warn(missing_docs)] #![warn(missing_debug_implementations)] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::panic::AssertUnwindSafe; use std::sync::Arc; @@ -128,6 +128,12 @@ pub struct TransformCtx<'a> { pub typechecking_ctx: &'a SharedTypecheckingContext, /// Transforms can use this field to communicate information outside the result plans. pub df_meta: &'a mut DataflowMetainfo, + /// Objects in this dataflow that are security barriers. + /// + /// A barrier's plan is never merged into a consumer's, and only leakproof + /// predicates from a consumer are pushed into it. See + /// `doc/developer/design/20260828_security_barrier_views.md`. + pub security_barriers: &'a BTreeSet, /// Metrics for the optimizer. pub metrics: Option<&'a mut OptimizerMetrics>, /// The last hash of the query, if known. @@ -136,6 +142,9 @@ pub struct TransformCtx<'a> { const FOLD_CONSTANTS_LIMIT: usize = 10000; +/// The empty barrier set, for contexts that cannot contain a barrier. +static NO_SECURITY_BARRIERS: BTreeSet = BTreeSet::new(); + impl<'a> TransformCtx<'a> { /// Generates a [`TransformCtx`] instance for the local MIR optimization /// stage. @@ -158,6 +167,9 @@ impl<'a> TransformCtx<'a> { typechecking_ctx: typecheck_ctx, df_meta, metrics, + // Local optimization sees a single expression with no cross-object + // boundaries, so there is nothing for a barrier to protect. + security_barriers: &NO_SECURITY_BARRIERS, last_hash: Default::default(), } } @@ -173,6 +185,7 @@ impl<'a> TransformCtx<'a> { typecheck_ctx: &'a SharedTypecheckingContext, df_meta: &'a mut DataflowMetainfo, metrics: Option<&'a mut OptimizerMetrics>, + security_barriers: &'a BTreeSet, ) -> Self { Self { indexes, @@ -182,6 +195,7 @@ impl<'a> TransformCtx<'a> { df_meta, typechecking_ctx: typecheck_ctx, metrics, + security_barriers, last_hash: Default::default(), } } diff --git a/src/transform/tests/test_security_barrier.rs b/src/transform/tests/test_security_barrier.rs new file mode 100644 index 0000000000000..3feacc9f24d69 --- /dev/null +++ b/src/transform/tests/test_security_barrier.rs @@ -0,0 +1,153 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! Tests for security barrier views +//! ([`mz_transform::dataflow::optimize_dataflow_filters_inner`]). +//! +//! The fixture in each test is a two-object dataflow shaped like the access +//! control pattern: `mine` filters `orders` down to one tenant, and `q` is an +//! untrusted consumer that reads `mine`. Column `#0` is the secret the reader +//! must not learn and `#1` is the tenant the view filters on. + +use std::collections::{BTreeMap, BTreeSet}; + +use mz_expr::{Id, MirRelationExpr, MirScalarExpr}; +use mz_expr_parser::{TestCatalog, try_parse_mir}; +use mz_repr::explain::ExplainConfig; +use mz_repr::{GlobalId, SqlColumnType, SqlRelationType, SqlScalarType}; +use mz_transform::dataflow::optimize_dataflow_filters_inner; + +/// A fallible predicate. `DivInt64` errors on a zero divisor, and the error +/// message names the row, so this is the shape a reader uses as an oracle. +const LEAKY: &str = "(100 / #0) > 0"; + +/// An infallible predicate over the same secret column. +const LEAKPROOF: &str = "#0 = 5"; + +struct Fixture { + catalog: TestCatalog, + orders: GlobalId, + /// `(id, plan)` for `mine` then `q`, in dependency order. + objects: Vec<(GlobalId, MirRelationExpr)>, +} + +/// Builds the two-object dataflow with `user_predicate` applied by the consumer. +fn fixture(user_predicate: &str) -> Fixture { + let mut catalog = TestCatalog::default(); + + let column_types = (0..2) + .map(|_| SqlColumnType { + scalar_type: SqlScalarType::Int64, + nullable: true, + }) + .collect(); + let orders = catalog + .insert( + "orders", + vec!["secret".into(), "tenant".into()], + SqlRelationType { + column_types, + keys: vec![], + }, + false, + ) + .unwrap(); + + let specs = [ + ("mine", "Filter (#1 = 7)\n Get orders".to_string()), + ("q", format!("Filter ({user_predicate})\n Get mine")), + ]; + let objects = specs + .iter() + .map(|(name, spec)| { + let rel = try_parse_mir(&catalog, spec).unwrap(); + let cols = (0..rel.arity()).map(|i| format!("c{i}")).collect(); + let id = catalog.insert(name, cols, rel.sql_typ(), false).unwrap(); + (id, rel) + }) + .collect(); + + Fixture { + catalog, + orders, + objects, + } +} + +/// Runs cross-view filter pushdown, treating `barriers` as security barriers. +/// Returns the predicates that reached the `orders` source import. +fn push_filters(f: &mut Fixture, barriers: &BTreeSet) -> Vec { + let mut predicates = BTreeMap::>::new(); + optimize_dataflow_filters_inner( + f.objects + .iter_mut() + .map(|(id, rel)| (Id::Global(*id), rel)) + .rev(), + &mut predicates, + barriers, + ) + .unwrap(); + predicates + .get(&Id::Global(f.orders)) + .map(|list| list.iter().map(|p| p.to_string()).collect()) + .unwrap_or_default() +} + +fn plan(f: &Fixture, which: usize) -> String { + f.objects[which] + .1 + .debug_explain(&ExplainConfig::default(), Some(&f.catalog)) + .trim() + .to_string() +} + +/// Without a barrier, a fallible reader predicate is spliced into the view's +/// own plan and propagates to the base collection. This pins the exposure the +/// barrier exists to close; it is expected to change if the default changes. +#[mz_ore::test] +#[cfg_attr(miri, ignore)] // can't call foreign function `rust_psm_stack_pointer` on OS `linux` +fn unprotected_view_leaks_reader_predicate() { + let mut f = fixture(LEAKY); + let at_source = push_filters(&mut f, &BTreeSet::new()); + + assert_eq!( + plan(&f, 0), + "Filter (#1 = 7) AND ((100 / #0) > 0)\n Get orders" + ); + assert_eq!(at_source, vec!["(#1 = 7)", "((100 / #0) > 0)"]); +} + +/// With a barrier, the fallible predicate stays above the consumer's `Get`, so +/// it is evaluated only against rows the tenant filter already admitted, and it +/// never reaches the base collection. +#[mz_ore::test] +#[cfg_attr(miri, ignore)] // can't call foreign function `rust_psm_stack_pointer` on OS `linux` +fn barrier_view_retains_reader_predicate() { + let mut f = fixture(LEAKY); + let barriers = BTreeSet::from([f.objects[0].0]); + let at_source = push_filters(&mut f, &barriers); + + assert_eq!(plan(&f, 0), "Filter (#1 = 7)\n Get orders"); + assert_eq!(plan(&f, 1), "Filter ((100 / #0) > 0)\n Get mine"); + assert_eq!(at_source, vec!["(#1 = 7)"]); +} + +/// A barrier is not a wall. An infallible predicate has no channel to leak +/// through, so it still crosses and still reaches the source import, where +/// persist filter pushdown can use it. +#[mz_ore::test] +#[cfg_attr(miri, ignore)] // can't call foreign function `rust_psm_stack_pointer` on OS `linux` +fn barrier_view_admits_leakproof_predicate() { + let mut f = fixture(LEAKPROOF); + let barriers = BTreeSet::from([f.objects[0].0]); + let at_source = push_filters(&mut f, &barriers); + + assert_eq!(plan(&f, 0), "Filter (#0 = 5) AND (#1 = 7)\n Get orders"); + assert_eq!(at_source, vec!["(#0 = 5)", "(#1 = 7)"]); +} diff --git a/test/sqllogictest/security_barrier.slt b/test/sqllogictest/security_barrier.slt new file mode 100644 index 0000000000000..2e5380b45cab1 --- /dev/null +++ b/test/sqllogictest/security_barrier.slt @@ -0,0 +1,93 @@ +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. + +# Security barrier views. +# See doc/developer/design/20260828_security_barrier_views.md + +mode cockroach + +statement ok +CREATE TABLE orders (tenant text, secret text, amount int) + +statement ok +CREATE VIEW plain_orders AS SELECT * FROM orders WHERE tenant = 'alice' + +statement ok +CREATE VIEW barrier_orders WITH (SECURITY BARRIER) AS SELECT * FROM orders WHERE tenant = 'alice' + +# The option round-trips through `create_sql`. +query TT +SHOW CREATE VIEW barrier_orders +---- +materialize.public.barrier_orders CREATE␠VIEW⏎␠␠␠␠materialize.public.barrier_orders⏎␠␠␠␠WITH␠(SECURITY␠BARRIER)⏎␠␠␠␠AS␠SELECT␠*␠FROM␠materialize.public.orders␠WHERE␠tenant␠=␠'alice'; + +# `= false` is accepted and is the default. +statement ok +CREATE VIEW explicit_false WITH (SECURITY BARRIER = false) AS SELECT * FROM orders + +statement error Expected SECURITY, found identifier "nonsense" +CREATE VIEW bad_option WITH (NONSENSE) AS SELECT * FROM orders + +# Without a barrier, a fallible reader predicate is pushed below the view's own +# filter and reaches the base collection. +query T multiline +EXPLAIN OPTIMIZED PLAN AS VERBOSE TEXT FOR +SELECT * FROM plain_orders WHERE amount / (length(secret) - 3) > 0 +---- +Explained Query: + Filter (#0{tenant} = "alice") AND ((#2{amount} / (char_length(#1{secret}) - 3)) > 0) + ReadStorage materialize.public.orders + +Source materialize.public.orders + filter=((#0{tenant} = "alice") AND ((#2{amount} / (char_length(#1{secret}) - 3)) > 0)) + +Target cluster: quickstart + +EOF + +# With a barrier, the fallible predicate stays above the view, which is read +# through a separate object, and only the tenant filter reaches `orders`. +query T multiline +EXPLAIN OPTIMIZED PLAN AS VERBOSE TEXT FOR +SELECT * FROM barrier_orders WHERE amount / (length(secret) - 3) > 0 +---- +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")) + +Target cluster: quickstart + +EOF + +# A leakproof predicate still crosses the barrier. +query T multiline +EXPLAIN OPTIMIZED PLAN AS VERBOSE TEXT FOR +SELECT * FROM barrier_orders WHERE amount = 42 +---- +Explained Query: + Filter (#2{amount} = 42) + ReadGlobalFromSameDataflow materialize.public.barrier_orders + +materialize.public.barrier_orders: + Filter (#0{tenant} = "alice") AND (#2{amount} = 42) + ReadStorage materialize.public.orders + +Source materialize.public.orders + filter=((#0{tenant} = "alice") AND (#2{amount} = 42)) + +Target cluster: quickstart + +EOF