micromegas_analytics/lakehouse/ownership_rewrite.rs
1//! `OwnershipRewrite` -- the row-level filter.
2//!
3//! An `AnalyzerRule` that injects an audience-filtering predicate into every
4//! `MaterializedView`-backed `TableScan` a query plan touches, based on the caller's `ReadScope`
5//! (`read_scope.rs`) and [`crate::lakehouse::read_scope::IsolationConfig`]'s
6//! `MICROMEGAS_PUBLIC_VIEW_SETS`.
7//!
8//! ## `ReadScope::All` is a true no-op
9//!
10//! `ReadScope::All` is the internal/maintenance marker -- never the output of a real `ReadPolicy`
11//! (see `read_scope.rs`). `analyze()` returns the plan unchanged when constructed with it, and
12//! `query.rs::make_session_context` does not even construct this rule for `ReadScope::All`
13//! callers, so their `ViewFactory` need not carry `processes`/`streams` either.
14//!
15//! ## The physical `audience` column
16//!
17//! `blocks`, `processes`, `streams`, `log_entries`, `measures`, and `log_stats` each carry a
18//! physical `audience` column -- declared non-nullable on `blocks`/`log_entries`/`measures`, and
19//! non-null by construction (though declared nullable in their inferred `SqlBatchView` schema) on
20//! `processes`/`streams`/`log_stats` -- extracted once from Postgres's own `audience` column at
21//! the `blocks` view's materialization (its own row's stamp) and propagated structurally into
22//! every downstream view. A DDL-defined materialized view carrying its own `audience` column
23//! (enforced at `CREATE` time, see `view_definition.rs`) joins this group too. Every view
24//! carrying an `audience` column is consequently filtered with a bare `Filter` on that column
25//! (the column-filter branch in the table below): no semi-join, no `property_get`, no
26//! per-process aggregate. `async_events`, `thread_spans`, `net_spans`, `otel_spans`, and `images`
27//! don't carry the column. They keep the `process_id`/`EXISTS` machinery below, which still
28//! resolves through `__processes__partitions`, but only for a scan that is *not* a guarded
29//! `view_instance(...)` -- see the skip rule in the Branch table below.
30//!
31//! ## One audience per row, not derived from the process (surviving branches only)
32//!
33//! `__processes__partitions` (the raw, un-merged `SqlBatchView` partitions `self.processes_source`
34//! scans) can carry more than one historical row per `process_id` -- the partition-level
35//! `GROUP BY` in `processes_view.rs`'s transform query only collapses rows *within* a partition,
36//! not across a long-lived process's entire history. Filtering the views carrying an `audience`
37//! column, DDL-defined ones included, one row at a time (the column-filter branch) is sound
38//! without any aggregate: every row now carries
39//! its own stamp from the moment it was written, so there is nothing to reconcile across rows of
40//! the same process. The one exception is a legacy, pre-v8 row with no `audience` column at all --
41//! `COALESCE` resolves that to the deployment default at read time. The `net_spans`/
42//! `otel_spans`/`images` semi-join branch and the `async_events`/`thread_spans` `EXISTS` branches
43//! still resolve through `per_process_audience` -- `Aggregate(GROUP BY process_id, MAX(audience)
44//! AS resolved_audience)` -- for a scan of those five that is *not* a guarded instance, since they
45//! have no `audience` column of their own to filter directly. A guarded instance skips the
46//! predicate entirely (see the Branch table below).
47//!
48//! ## Branch table
49//!
50//! Keyed on `MaterializedView::get_view().get_file_schema()`: whether it has an `audience` field,
51//! failing that whether it has a `process_id` field -- not on a hardcoded view-set list, so a
52//! future view set that gains either column falls into the matching branch automatically:
53//!
54//! | View set | Branch |
55//! |---|---|
56//! | `processes`, `streams`, `blocks`, `log_entries`, `measures`, `log_stats`, and any DDL-defined view carrying an `audience` column | `column filter`: direct `audience IN (...)` filter on the view's own column -- no join |
57//! | `net_spans`, `otel_spans`, `images`, `async_events`, `thread_spans`, scanned as a guarded, non-`'global'` `view_instance(...)` | `skip`: no predicate at all -- `MaterializedView::instance_is_audience_guarded()` reports `AudienceGuard::authorize_view_instance` already denies an unauthorized caller before any row is read, so the subquery predicates below would be redundant |
58//! | `net_spans`, `otel_spans`, `images`, not a guarded instance | `semi-join`: `process_id IN (subquery)` against `per_process_audience` (outer `process_id` cast to `Utf8`: it is `Dictionary(Int32, Utf8)` in these, and nothing coerces an uncorrelated `IN` subquery's join keys once `DecorrelatePredicateSubquery` turns it into a `LeftSemi` join -- the analyzer's own `TypeCoercion` has already run by the time this rule executes) |
59//! | `async_events`, not a guarded instance | no `process_id` column either -- `async_events EXISTS`: literal-valued `EXISTS`, keyed on `get_view_instance_id()` (the process_id string, canonicalized -- see `canonical_view_instance_id`) |
60//! | `thread_spans`, not a guarded instance | no `process_id` **or** `stream_id` column -- `thread_spans EXISTS`: two-hop literal `EXISTS`, through `streams` (`get_view_instance_id()` is the stream_id string, canonicalized the same way) |
61//! | anything in `public_view_sets` | `public`: no predicate at all -- checked before any of the above |
62//! | anything else | `analyze()` returns `Err` (`DataFusionError::Plan`) rather than silently leaving the scan unfiltered -- a future view set must add itself to this table, not fall through |
63//!
64//! Adding a new view set to a `ViewFactory` means adding it to this table too, and to the match
65//! arms in `OwnershipRewrite::predicate_for` below.
66//!
67//! ## No I/O in `analyze()`
68//!
69//! `in_subquery`/`exists` build uncorrelated subquery `Expr`s; `DecorrelatePredicateSubquery` (an
70//! optimizer, not analyzer, rule) turns them into joins later. `processes_source`/`streams_source`
71//! come from `MaterializedView::new(..., query_range: None)`, which is synchronous and does no I/O
72//! -- the actual `processes`/`streams` scans happen during normal execution, exactly like
73//! `TableScanRewrite`'s injected time filter.
74//!
75//! ## The `audience` column is server-written and authenticated
76//!
77//! `processes`, `streams`, and `blocks` are each stamped with their own `audience` column at
78//! write time, from the authenticated credential's `AuthContext.bound_audience`
79//! (`resolve_write_audience`), and a client can neither assert nor forge that stamp
80//! (`strip_reserved_properties` drops any client-supplied `micromegas.*` property, and there is
81//! no property to re-assert in the first place any more -- the stamp is a physical column, not a
82//! property). A credential carrying no bound audience resolves to the deployment default at the
83//! HTTP write edge and is stamped with it explicitly, the same as any other audience -- only a
84//! legacy, pre-v8 row keeps a NULL column, resolved to the deployment's
85//! `MICROMEGAS_DEFAULT_AUDIENCE` where the audience is *read* instead (see `crate::audience`'s
86//! module doc). Admin replication (`replication.rs`) now hard-fails on a missing `audience`
87//! column rather than ever writing a row with none. Registration
88//! (`insert_process`/`register_otel_process`) rejects a same-`process_id`, different-audience
89//! re-registration outright -- needed because the OTLP `process_id` derivation formula is public,
90//! so without this guard a credential could pre-register (via the native path) the exact
91//! `process_id` a victim audience's OTLP producer would later derive, silently exposing that
92//! audience's data to the squatter; this is a confidentiality gap, not merely an integrity one.
93//! The stream-side mirror, `check_stream_audience_conflict`, closes the equivalent
94//! re-pointed-credential gap for `streams`.
95//!
96//! **What remains open, tracked separately:**
97//! - **The five instance-only view sets** (`net_spans`, `otel_spans`, `images`, `async_events`,
98//! `thread_spans`) **and the per-process JIT `view_instance` path** still resolve their audience
99//! *label* through the owning process's/stream's row rather than a genuine per-row column of
100//! their own -- via the call-level guard's instance check (`AudienceGuard::authorize_view_instance`,
101//! `IdKind::ProcessOrStream`) for the guarded `view_instance(...)` scan every production caller
102//! takes, not by a predicate this rule injects (see the skip in the Branch table above). The
103//! cross-audience *injection* scenario (an attacker's block naming a victim's
104//! `process_id`/`stream_id`) is closed for both, as a side effect of the mismatch predicate on
105//! `blocks_view`'s own materialization -- except against a victim whose `processes`/`streams`
106//! row is a legacy, pre-v8 NULL-audience row (next bullet).
107//! - **The NULL-anchor window.** A `processes`/`streams` row registered before its ingestion
108//! binary supported per-row stamping keeps `audience = NULL` for its entire remaining life
109//! (rows are immutable, no backfill). The mismatch predicate's NULL-tolerant pass-through lets
110//! a mismatched block through unchecked against such a row for as long as it exists. This is
111//! an accepted, bounded limitation over already-public legacy data, bounded by the deploy-order
112//! requirement (every ingestion replica upgraded) and shrinking as legacy rows age out under
113//! retention.
114//!
115//! There is no in-product enforcement knob left for either surface; the mitigation is
116//! operational -- provision only audience-bound DB-backed ingestion credentials, and don't run
117//! ingestion with OIDC or `--disable-auth` alongside them -- see this stage's `CHANGELOG.md`
118//! entry.
119
120use super::{materialized_view::MaterializedView, read_scope::ReadScope};
121use datafusion::{
122 arrow::datatypes::{DataType, Field},
123 common::{Column, TableReference, tree_node::Transformed},
124 config::ConfigOptions,
125 datasource::DefaultTableSource,
126 error::DataFusionError,
127 functions_aggregate::min_max::max,
128 logical_expr::{Filter, LogicalPlan, LogicalPlanBuilder, TableSource},
129 optimizer::AnalyzerRule,
130 prelude::*,
131 scalar::ScalarValue,
132};
133use std::sync::Arc;
134use uuid::Uuid;
135
136/// Injects an audience predicate into every `MaterializedView` scan (see the module doc comment).
137pub struct OwnershipRewrite {
138 read_scope: ReadScope,
139 public_view_sets: Vec<String>,
140 processes_source: Arc<dyn TableSource>,
141 streams_source: Arc<dyn TableSource>,
142}
143
144impl std::fmt::Debug for OwnershipRewrite {
145 /// `dyn TableSource` carries no `Debug` impl, so `processes_source`/`streams_source` are
146 /// named but not expanded.
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("OwnershipRewrite")
149 .field("read_scope", &self.read_scope)
150 .field("public_view_sets", &self.public_view_sets)
151 .field("processes_source", &"<dyn TableSource>")
152 .field("streams_source", &"<dyn TableSource>")
153 .finish()
154 }
155}
156
157impl OwnershipRewrite {
158 /// `processes_source`/`streams_source` must be built from `MaterializedView::new(...,
159 /// query_range: None)` over the raw partitions (equivalent to `__processes__partitions` /
160 /// `__streams__partitions`) -- see `query.rs::make_session_context`: the audience lookup must
161 /// be time-unbounded, and must not go through the `SqlBatchView`-merged `processes`/`streams`
162 /// named tables. Used only by the semi-join/`async_events`/`thread_spans` branches -- the
163 /// views carrying an `audience` column, DDL-defined ones included, need neither.
164 pub fn new(
165 read_scope: ReadScope,
166 public_view_sets: Vec<String>,
167 processes_source: Arc<dyn TableSource>,
168 streams_source: Arc<dyn TableSource>,
169 ) -> Self {
170 Self {
171 read_scope,
172 public_view_sets,
173 processes_source,
174 streams_source,
175 }
176 }
177
178 /// `Aggregate(GROUP BY process_id, MAX(audience) AS resolved_audience)` over the raw,
179 /// time-unbounded `processes` partitions -- built once per `analyze()` call and reused at
180 /// every scan site the traversal visits (see the module doc comment). Only reached by the
181 /// semi-join/`async_events`/`thread_spans` branches now that `processes` itself carries a
182 /// physical `audience` column and filters it directly (the column-filter branch); the
183 /// aggregate still exists for those three because they have no `audience` column of their
184 /// own, not to reconcile disagreeing rows -- a process's own `audience` column never
185 /// disagrees across rows, so `MAX` is just a way to pull the single value out of the group.
186 fn per_process_audience(&self) -> datafusion::error::Result<LogicalPlan> {
187 LogicalPlanBuilder::scan(
188 "__processes__partitions",
189 self.processes_source.clone(),
190 None,
191 )?
192 .aggregate(
193 vec![col("process_id")],
194 vec![max(col("audience")).alias("resolved_audience")],
195 )?
196 .build()
197 }
198
199 /// The caller's audience set. Only ever called after `analyze()` has short-circuited on
200 /// `ReadScope::All`, so the `All` arm here is unreachable in practice; it degrades to "no
201 /// audiences" rather than panicking if that invariant is ever violated.
202 fn audiences(&self) -> &[String] {
203 match &self.read_scope {
204 ReadScope::All => &[],
205 ReadScope::Audiences(a) => a,
206 }
207 }
208
209 /// `resolved_audience IN (audiences)`, or `lit(false)` when `audiences` is empty -- the
210 /// fail-closed reading of "caller has no audiences" rather than emitting `IN ()` and leaving
211 /// its behavior to DataFusion (`ReadScope::Audiences` can legitimately resolve to an empty
212 /// set -- `public` has no built-in read grant, so a caller matching no grant at all,
213 /// including the seeded `('public', 'read', '*')` row, resolves to the empty set, and that
214 /// empty set is now reachable in production, not just in tests). No `coalesce` here: the column is
215 /// non-null by construction, because the extraction sites already coalesced a
216 /// never-stamped process's missing property to the deployment default before it was
217 /// materialized.
218 fn resolved_predicate(&self) -> Expr {
219 let audiences = self.audiences();
220 if audiences.is_empty() {
221 return lit(false);
222 }
223 col("resolved_audience").in_list(
224 audiences
225 .iter()
226 .map(|a| lit(ScalarValue::Utf8(Some(a.clone()))))
227 .collect(),
228 false,
229 )
230 }
231
232 /// The column-filter branch: `audience IN (caller audiences)`; `false` for an empty set
233 /// (fail-closed, as [`Self::resolved_predicate`] already does). The column is declared `NOT NULL` on
234 /// `blocks`/`log_entries`/`measures` and non-null by construction (though declared nullable)
235 /// on `processes`/`streams`/`log_stats`, so there is no unstamped case either way -- a
236 /// DDL-defined view's `audience` column is a documented author obligation to keep non-`NULL`
237 /// the same way (see `view_definition.rs`).
238 fn audience_column_predicate(&self, table_name: &TableReference, field: &Field) -> Expr {
239 let audiences = self.audiences();
240 if audiences.is_empty() {
241 return lit(false);
242 }
243 let raw = Expr::Column(Column::new(Some(table_name.clone()), "audience"));
244 // This rule runs after DataFusion's own TypeCoercion pass, so the Dictionary(Int32, Utf8)
245 // column every audience-carrying view carries must be cast to compare against Utf8
246 // literals. The cast is not a pruning barrier: `PruningPredicate` rewrites `cast(col) op
247 // lit` by applying the same cast to the column's min/max statistics
248 // (`datafusion-pruning`'s `rewrite_expr_to_prunable`), and a Dictionary -> Utf8 cast is on
249 // its supported list (`verify_support_type_for_prune` unwraps dictionaries), with `IN`
250 // lists up to 20 items expanded to such comparisons. The bare-column arm below is
251 // therefore just a no-op-cast skip, kept for a future view that carries plain `Utf8`.
252 let lhs = if field.data_type() == &DataType::Utf8 {
253 raw
254 } else {
255 cast(raw, DataType::Utf8)
256 };
257 lhs.in_list(
258 audiences
259 .iter()
260 .map(|a| lit(ScalarValue::Utf8(Some(a.clone()))))
261 .collect(),
262 false,
263 )
264 }
265
266 /// The semi-join branch's shared `IN`-subquery plan: `per_process_audience` filtered by
267 /// `resolved_predicate`, projected down to `process_id` alone -- the one column an
268 /// `InSubquery` may project.
269 fn in_subquery_plan(
270 per_process_audience: &LogicalPlan,
271 resolved_predicate: &Expr,
272 ) -> datafusion::error::Result<Arc<LogicalPlan>> {
273 Ok(Arc::new(
274 LogicalPlanBuilder::from(per_process_audience.clone())
275 .filter(resolved_predicate.clone())?
276 .project(vec![col("process_id")])?
277 .build()?,
278 ))
279 }
280
281 /// The `async_events` branch's literal-valued `EXISTS`: `per_process_audience` filtered down
282 /// to the single process named by `process_id_literal`, conjuncted with `resolved_predicate`.
283 /// No projection: unlike
284 /// `InSubquery`, `EXISTS` has no single-column-projection requirement, so the two-column
285 /// (`process_id`, `resolved_audience`) shape is fine.
286 fn exists_for_process(
287 per_process_audience: &LogicalPlan,
288 resolved_predicate: &Expr,
289 process_id_literal: &str,
290 ) -> datafusion::error::Result<Expr> {
291 let subquery = LogicalPlanBuilder::from(per_process_audience.clone())
292 .filter(
293 col("process_id")
294 .eq(lit(process_id_literal))
295 .and(resolved_predicate.clone()),
296 )?
297 .build()?;
298 Ok(exists(Arc::new(subquery)))
299 }
300
301 /// The `thread_spans` branch's two-hop literal `EXISTS`: resolve `stream_id_literal` (the
302 /// `thread_spans` `view_instance_id`) through `streams` into the process it belongs to, then
303 /// apply the same `per_process_audience`/`resolved_predicate` check the column-filter branch
304 /// uses directly.
305 fn exists_for_stream(
306 &self,
307 per_process_audience: &LogicalPlan,
308 resolved_predicate: &Expr,
309 stream_id_literal: &str,
310 ) -> datafusion::error::Result<Expr> {
311 let subquery =
312 LogicalPlanBuilder::scan("__streams__partitions", self.streams_source.clone(), None)?
313 .filter(col("stream_id").eq(lit(stream_id_literal)))?
314 .join(
315 LogicalPlanBuilder::from(per_process_audience.clone())
316 .filter(resolved_predicate.clone())?
317 .build()?,
318 JoinType::Inner,
319 (vec!["process_id"], vec!["process_id"]),
320 None,
321 )?
322 .build()?;
323 Ok(exists(Arc::new(subquery)))
324 }
325
326 /// Parses `view.get_view_instance_id()` as a `Uuid` and returns its canonical
327 /// lowercase-hyphenated string form -- the form actually stored in `process_id`/`stream_id`
328 /// columns. `Uuid::parse_str` accepts several equivalent textual spellings (uppercase,
329 /// hyphen-less, braced) of the same UUID, but
330 /// only this canonical form round-trips through the data; using the raw, caller-supplied
331 /// spelling directly in a literal comparison would silently fail to match for any other
332 /// spelling of a legitimately materialized instance id. Defensive `Err` on parse failure --
333 /// the view constructors (`AsyncEventsView::new`/`ThreadSpansView::new`) already validate this
334 /// as a UUID at construction time, so this should not happen in practice.
335 fn canonical_view_instance_id(
336 view_set_name: &Arc<String>,
337 view: &Arc<dyn super::view::View>,
338 ) -> datafusion::error::Result<String> {
339 let raw = view.get_view_instance_id();
340 Uuid::parse_str(raw.as_str())
341 .map(|uuid| uuid.hyphenated().to_string())
342 .map_err(|e| {
343 DataFusionError::Plan(format!(
344 "OwnershipRewrite: view set '{view_set_name}' view_instance_id '{raw}' is \
345 not a valid UUID: {e}"
346 ))
347 })
348 }
349
350 /// Builds the predicate to wrap `mat_view`'s scan in. `Ok(None)` means "no predicate at all",
351 /// for either of two reasons: the public-view-set skip, or a guarded `view_instance(...)`
352 /// scan that `MaterializedView::instance_is_audience_guarded()` reports is already fully
353 /// authorized by `AudienceGuard::authorize_view_instance` (the call-level guard); `Err` is the fallback
354 /// for a view set matching none of the branches -- see the module doc comment's branch table.
355 fn predicate_for(
356 &self,
357 table_name: &TableReference,
358 mat_view: &MaterializedView,
359 per_process_audience: &LogicalPlan,
360 resolved_predicate: &Expr,
361 in_subquery_plan: &Arc<LogicalPlan>,
362 ) -> datafusion::error::Result<Option<Expr>> {
363 let view = mat_view.get_view();
364 let view_set_name = view.get_view_set_name();
365 if self
366 .public_view_sets
367 .iter()
368 .any(|s| s.as_str() == view_set_name.as_str())
369 {
370 return Ok(None);
371 }
372 // A guarded, non-`'global'` `view_instance(...)` scan is already fully authorized by
373 // `AudienceGuard::authorize_view_instance` before any row is read (see
374 // `MaterializedView::instance_is_audience_guarded`'s doc comment), so the semi-join/
375 // `async_events`/`thread_spans` subquery predicates below are redundant for it. This
376 // assumes a guarded instance's rows can never legitimately span more than one process --
377 // true for today's five view sets, but a future guarded view set whose instance can
378 // legitimately span more than one process would need to revisit this before its arm gets
379 // the same skip.
380 let skip = mat_view.instance_is_audience_guarded();
381 // The column-filter branch: views carrying a physical `audience` column -- processes,
382 // streams, blocks, log_entries, measures, log_stats (global and per-process instances
383 // alike), and any DDL-defined view carrying the column. Filtered directly, no semi-join,
384 // no property_get. Checked ahead of the semi-join branch so a view set that has *both* an
385 // `audience` and a `process_id` column (every one of these does) takes this cheaper
386 // branch; keyed on schema introspection, so a view set that gains the column later
387 // upgrades automatically with no edit here.
388 if let Ok(field) = view.get_file_schema().field_with_name("audience") {
389 return Ok(Some(self.audience_column_predicate(table_name, field)));
390 }
391 // Qualified with the outer scan's own table name: once `DecorrelatePredicateSubquery`
392 // joins this scan with `in_subquery_plan`'s subquery, the combined schema holds a
393 // `process_id` column from both sides, so a bare, unqualified `col("process_id")` is
394 // ambiguous and `into_optimized_plan()` fails.
395 let outer_process_id = Expr::Column(Column::new(Some(table_name.clone()), "process_id"));
396 if view.get_file_schema().field_with_name("process_id").is_ok() {
397 // The semi-join branch: process_id-**column** views with no `audience` column of
398 // their own -- net_spans, otel_spans, images.
399 if skip {
400 return Ok(None);
401 }
402 return Ok(Some(in_subquery(
403 cast(outer_process_id, DataType::Utf8),
404 in_subquery_plan.clone(),
405 )));
406 }
407 if view_set_name.as_str() == "async_events" {
408 // The async_events branch: process-scoped, no process_id column -- the
409 // view_instance_id *is* the process_id string (`AsyncEventsView::new` parses it as
410 // a `Uuid`). Canonicalized
411 // before building the literal -- see `canonical_view_instance_id`'s doc comment for why.
412 if skip {
413 return Ok(None);
414 }
415 let process_id_literal = Self::canonical_view_instance_id(&view_set_name, &view)?;
416 return Ok(Some(Self::exists_for_process(
417 per_process_audience,
418 resolved_predicate,
419 process_id_literal.as_str(),
420 )?));
421 }
422 if view_set_name.as_str() == "thread_spans" {
423 // The thread_spans branch: stream-scoped, no process_id or stream_id column --
424 // resolve through `streams`. Same canonicalization as the async_events branch, keyed
425 // on `stream_id` instead of `process_id`.
426 if skip {
427 return Ok(None);
428 }
429 let stream_id_literal = Self::canonical_view_instance_id(&view_set_name, &view)?;
430 return Ok(Some(self.exists_for_stream(
431 per_process_audience,
432 resolved_predicate,
433 stream_id_literal.as_str(),
434 )?));
435 }
436 Err(DataFusionError::Plan(format!(
437 "OwnershipRewrite: no audience rule defined for view set '{view_set_name}'"
438 )))
439 }
440
441 fn rewrite_plan(
442 &self,
443 plan: LogicalPlan,
444 per_process_audience: &LogicalPlan,
445 resolved_predicate: &Expr,
446 in_subquery_plan: &Arc<LogicalPlan>,
447 ) -> datafusion::error::Result<Transformed<LogicalPlan>> {
448 let LogicalPlan::TableScan(ts) = &plan else {
449 return Ok(Transformed::no(plan));
450 };
451 let Some(table_source) = ts.source.downcast_ref::<DefaultTableSource>() else {
452 return Ok(Transformed::no(plan));
453 };
454 let Some(mat_view) = table_source
455 .table_provider
456 .downcast_ref::<MaterializedView>()
457 else {
458 // Not a MaterializedView (e.g. a table function's output) -- nothing to filter.
459 return Ok(Transformed::no(plan));
460 };
461 let Some(predicate) = self.predicate_for(
462 &ts.table_name,
463 mat_view,
464 per_process_audience,
465 resolved_predicate,
466 in_subquery_plan,
467 )?
468 else {
469 // No predicate: a public view set, or a guarded `view_instance(...)` scan
470 // the call-level guard already authorized.
471 return Ok(Transformed::no(plan));
472 };
473 let filter = Filter::try_new(predicate, Arc::new(plan.clone()))?;
474 Ok(Transformed::yes(LogicalPlan::Filter(filter)))
475 }
476}
477
478impl AnalyzerRule for OwnershipRewrite {
479 fn name(&self) -> &str {
480 "ownership_rewrite"
481 }
482
483 fn analyze(
484 &self,
485 plan: LogicalPlan,
486 _options: &ConfigOptions,
487 ) -> datafusion::error::Result<LogicalPlan> {
488 if self.read_scope == ReadScope::All {
489 // The internal/maintenance marker -- never a ReadPolicy's output. A true no-op, not
490 // "filter to a set containing everything" (see the module doc comment).
491 return Ok(plan);
492 }
493 let per_process_audience = self.per_process_audience()?;
494 let resolved_predicate = self.resolved_predicate();
495 let in_subquery_plan = Self::in_subquery_plan(&per_process_audience, &resolved_predicate)?;
496 plan.transform_up_with_subqueries(|node| {
497 self.rewrite_plan(
498 node,
499 &per_process_audience,
500 &resolved_predicate,
501 &in_subquery_plan,
502 )
503 })
504 .map(|res| res.data)
505 }
506}