Skip to main content

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}