Skip to main content

micromegas_analytics/lakehouse/
audience_guard.rs

1//! The call-level guard -- arg-addressed guards for
2//! five entry points: the span/metadata UDTFs and the `get_payload` UDF that
3//! [`crate::lakehouse::ownership_rewrite::OwnershipRewrite`] (the row-level filter) structurally cannot reach (they bake
4//! their target id into a provider at plan time, return schemas with no `process_id` column to
5//! filter on, and some build their own inner session under `ReadScope::All`), plus
6//! `view_instance(...)`. For the six view sets carrying a physical `audience` column, the row-level filter
7//! *can* and does reach a `view_instance` scan too -- it row-filters it the same as the
8//! named-table form -- so this guard's job there is only to stop a caller from triggering JIT
9//! materialization (real compute and object-storage writes) for an instance outside their
10//! audiences before the row-level filter ever gets to run, a cost/availability concern rather than a
11//! confidentiality one. For the other five view sets (`net_spans`, `otel_spans`, `images`,
12//! `async_events`, `thread_spans` -- reachable only through a guarded `view_instance(...)`, never
13//! as a named table), the row-level filter injects no per-row predicate at all once this guard is in place --
14//! see [`crate::lakehouse::materialized_view::MaterializedView::instance_is_audience_guarded`] and
15//! `OwnershipRewrite`'s module doc for the other side of that coupling -- so this guard is their
16//! *sole* confidentiality enforcement, not a redundant belt-and-braces check.
17//!
18//! ## One cache, one question
19//!
20//! [`crate::lakehouse::audience_guard::AudienceIndex`] answers exactly one question, for three id kinds ([`crate::lakehouse::audience_guard::IdKind`]): what
21//! audience is *this id's own row* stamped with -- a process's for [`crate::lakehouse::audience_guard::IdKind::Process`], a
22//! block's own for [`crate::lakehouse::audience_guard::IdKind::Block`], a process's or a stream's own for
23//! [`crate::lakehouse::audience_guard::IdKind::ProcessOrStream`]. It resolves from **Postgres**, by a single-table, primary-key
24//! point query per row kind -- fresher and independent of materialization, unlike
25//! [`crate::lakehouse::ownership_rewrite::OwnershipRewrite`], which reads a daemon-materialized snapshot.
26//!
27//! ## Fail-closed
28//!
29//! [`crate::lakehouse::audience_guard::is_readable`] is the whole authorization rule, pure and offline-testable: `ReadScope::All`
30//! passes everything; `ReadScope::Audiences` denies [`crate::lakehouse::audience_guard::OwnerAudience::Unknown`] unconditionally
31//! and matches [`crate::lakehouse::audience_guard::OwnerAudience::Audience`] byte-exactly. There is no unstamped state any more: a
32//! process/stream/block registered through the HTTP ingestion path always carries a real,
33//! non-NULL `audience` column (a credential with no bound audience is stamped with the resolved
34//! deployment default explicitly); only a legacy, pre-v8 row keeps a NULL column, and
35//! `owner_query_sql`'s `COALESCE` resolves that to `MICROMEGAS_DEFAULT_AUDIENCE` here, so every
36//! *existing* id has a real owning audience either way. `Unknown` therefore means "no such row"
37//! -- deny, on the same fail-closed reasoning as before. One behaviour change: since each arm now
38//! reads its own table directly rather than joining through `processes`, a block or stream whose
39//! `process_id` no longer resolves (retention swept it, or it hasn't arrived yet) resolves to
40//! *its own* stamp instead of falling through to `Unknown` -- see `owner_query_sql`'s doc
41//! comment. An id ambiguous between a `process_id` and a `stream_id`
42//! interpretation ([`crate::lakehouse::audience_guard::OwnerAudience::Ambiguous`]) is readable only when every interpretation is --
43//! never by picking one arm over the other. A resolution *error* (Postgres unreachable) is a
44//! denial too -- [`crate::lakehouse::audience_guard::AudienceGuard::authorize`]/[`crate::lakehouse::audience_guard::AudienceGuard::readable_ids`] map it to a query
45//! failure, never to a readable verdict.
46//!
47//! ## No existence oracle
48//!
49//! Every guard denial and every "no such id" produce the same error text (e.g. `process_spans:
50//! 'xxx' not found or not accessible`) -- a distinct "permission denied" would let a caller
51//! enumerate which ids exist in other audiences. The server log (`debug!`) records the real
52//! reason so an operator can tell the two apart; the client cannot.
53
54use super::read_scope::{CallerContext, ReadScope};
55use anyhow::Context;
56use datafusion::common::plan_err;
57use datafusion::error::DataFusionError;
58use micromegas_tracing::prelude::*;
59use sqlx::Row;
60use std::collections::{HashMap, HashSet};
61use std::sync::Arc;
62use std::time::Duration;
63use uuid::Uuid;
64
65/// What a resolution attempt found. Every *existing* process resolves to an audience -- its own
66/// if it was stamped, `MICROMEGAS_DEFAULT_AUDIENCE` if it was not -- so `Unknown` is the
67/// only "not a real, readable audience" state left, and it means "no such row" (not yet
68/// ingested, or retention already deleted it). Deny, fail-closed.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum OwnerAudience {
71    Unknown,
72    Audience(Arc<str>),
73    /// The id resolved to more than one distinct owner under `IdKind::ProcessOrStream`'s two
74    /// arms -- a `process_id`/`stream_id` collision (see the variant's doc comment on
75    /// [`IdKind::ProcessOrStream`]). Fail-closed: [`is_readable`] treats this as readable only
76    /// when *every* one of the distinct owners would independently be readable, never by picking
77    /// one arm over the other.
78    Ambiguous(Vec<OwnerAudience>),
79}
80
81/// Which table resolves the id to its owning process. Also the cache's key discriminator: the
82/// same `Uuid` can legitimately be a `process_id` in one audience and a `stream_id`/`block_id` in
83/// another (all three are client-supplied at ingestion, with no cross-table uniqueness
84/// constraint), so caching by kind keeps those disjoint instead of relying on downstream
85/// emptiness to make a wrong-kind hit harmless.
86#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
87pub enum IdKind {
88    /// `processes.process_id`.
89    Process,
90    /// `blocks.block_id` -> `blocks.process_id` -> `processes`.
91    Block,
92    /// `list_partitions`' `view_instance_id`: either a `process_id` or a `stream_id`, resolved in
93    /// one round trip. Cached under its own key rather than reusing `Process`/`Block` entries or a
94    /// separate `streams` kind, so the `UNION ALL` result -- fail-closed on a collision between the
95    /// two arms, see `merge_owner_rows` -- is what actually gets cached. A second consumer,
96    /// [`AudienceGuard::authorize_view_instance`]'s scan-time check on `view_instance(...)`,
97    /// resolves its `view_instance_id` argument through the same kind, sharing cache entries with
98    /// `list_partitions`' row filter over the same id.
99    ProcessOrStream,
100}
101
102/// Default entry-count bound for [`AudienceIndex`]'s cache -- see [`super::lakehouse_context`].
103/// One `Uuid` + a short audience string is roughly 100 bytes, so 100k entries is ~10 MB: a fixed
104/// shape with no operational knob, mirroring `analytics-web-srv/src/data_source_cache.rs`'s
105/// hardcoded `.max_capacity(1000)` rather than `MICROMEGAS_METADATA_CACHE_MB`'s per-entry-weight
106/// variability.
107pub const DEFAULT_AUDIENCE_CACHE_ENTRIES: u64 = 100_000;
108
109/// Default freshness bound for [`AudienceIndex`]'s cache entries. The *only* invalidation
110/// mechanism: a process's Postgres row is written once and never updated in place, but a
111/// retention-then-re-export cycle can recreate the same `process_id` (deterministic UUIDv5 for
112/// OTLP) under fresh `properties`, and nothing else ever expires a stale cached answer. Bounds
113/// how long a re-derived process's audience can serve a stale answer.
114pub const DEFAULT_AUDIENCE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
115
116/// Merges possibly-duplicated `(id, audience)` rows into one [`OwnerAudience`] per id.
117/// Fail-closed on a collision: when `ProcessOrStream`'s two arms resolve the same id to
118/// *different* audiences (a `process_id`/`stream_id` collision -- both ids are client-supplied at
119/// ingestion, with no cross-table uniqueness constraint), neither arm wins over the other --
120/// the id maps to [`OwnerAudience::Ambiguous`], which [`is_readable`] only passes when every
121/// resolved audience is independently readable. `Process`/`Block` queries never produce more than
122/// one row per id, so this never triggers for them.
123///
124/// A `None` audience maps to [`OwnerAudience::Unknown`], not a distinct state. With
125/// `owner_query_sql`'s `COALESCE` in place, a `None` is unreachable for a row that exists
126/// -- a missing property resolves to the default instead -- so this arm is a safety net against a
127/// bug in that expression, and it denies exactly like "no such row".
128fn merge_owner_rows(rows: Vec<(Uuid, Option<String>)>) -> HashMap<Uuid, OwnerAudience> {
129    let mut by_id: HashMap<Uuid, Vec<OwnerAudience>> = HashMap::new();
130    for (id, audience) in rows {
131        let owner = match audience {
132            Some(a) => OwnerAudience::Audience(Arc::from(a)),
133            None => OwnerAudience::Unknown,
134        };
135        let distinct_owners = by_id.entry(id).or_default();
136        if !distinct_owners.contains(&owner) {
137            distinct_owners.push(owner);
138        }
139    }
140    by_id
141        .into_iter()
142        .map(|(id, mut owners)| {
143            let owner = if owners.len() == 1 {
144                owners.pop().expect("checked len() == 1 above")
145            } else {
146                debug!(
147                    "audience_guard: '{id}' resolved to {} distinct owners across process_id/stream_id \
148                     collision ({owners:?}), treating as Ambiguous (fail-closed)",
149                    owners.len()
150                );
151                OwnerAudience::Ambiguous(owners)
152            };
153            (id, owner)
154        })
155        .collect()
156}
157
158/// The SQL shape for each [`IdKind`]: every arm reads the row's own `audience` column directly --
159/// a single-table point query on a primary-key-indexed column, no join and no `unnest`.
160/// `IdKind::Block` resolves off `blocks.audience` alone (the block's own stamp, never derived
161/// from the `process_id` it claims); the `ProcessOrStream` arm resolves
162/// `processes.audience`/`streams.audience` directly, each on its own table.
163///
164/// Behaviour change: a block or stream whose `process_id` no longer has a `processes` row
165/// (retention swept it, or it hasn't arrived yet) now resolves to its own stamp instead of
166/// falling through to [`OwnerAudience::Unknown`] via a join. `get_payload` is unaffected, since
167/// it authorizes via `IdKind::Process`, never `IdKind::Block`.
168///
169/// `$1` is the batch of ids to resolve, bound as a `uuid[]` array so `resolve_many` is always one
170/// query; `$2` is the default audience.
171///
172/// This site reads Postgres live, so it always resolves the *current* default -- unlike the row-level filter,
173/// which reads whatever default was configured when the partition was materialized. The two
174/// agree except across a default change that has not been followed by a regeneration pass.
175fn owner_query_sql(kind: IdKind) -> &'static str {
176    match kind {
177        IdKind::Process => {
178            "SELECT process_id AS id, COALESCE(audience, $2) AS audience
179             FROM processes WHERE process_id = ANY($1::uuid[])"
180        }
181        IdKind::Block => {
182            "SELECT block_id AS id, COALESCE(audience, $2) AS audience
183             FROM blocks WHERE block_id = ANY($1::uuid[])"
184        }
185        IdKind::ProcessOrStream => {
186            "SELECT process_id AS id, COALESCE(audience, $2) AS audience
187             FROM processes WHERE process_id = ANY($1::uuid[])
188             UNION ALL
189             SELECT stream_id AS id, COALESCE(audience, $2) AS audience
190             FROM streams WHERE stream_id = ANY($1::uuid[])"
191        }
192    }
193}
194
195/// Exposes [`owner_query_sql`] to integration tests, which otherwise can't reach a private
196/// function -- mirrors `web_ingestion_service.rs`'s `check_process_audience_conflict_for_test`
197/// naming convention. Intended for testing only.
198#[doc(hidden)]
199pub fn owner_query_sql_for_test(kind: IdKind) -> &'static str {
200    owner_query_sql(kind)
201}
202
203async fn fetch_owner_rows(
204    pool: &sqlx::Pool<sqlx::Postgres>,
205    ids: &[Uuid],
206    kind: IdKind,
207    default_audience: &str,
208) -> anyhow::Result<Vec<(Uuid, Option<String>)>> {
209    let rows = sqlx::query(owner_query_sql(kind))
210        .bind(ids)
211        .bind(default_audience)
212        .fetch_all(pool)
213        .await
214        .with_context(|| format!("resolving owning process' audience for {kind:?}"))?;
215    rows.into_iter()
216        .map(|row| {
217            let id: Uuid = row.try_get("id").context("reading id column")?;
218            let audience: Option<String> =
219                row.try_get("audience").context("reading audience column")?;
220            Ok((id, audience))
221        })
222        .collect()
223}
224
225/// Resolves *any telemetry id -> its owning process's audience*, from Postgres, cached and
226/// TTL-bounded. See the module doc comment for the full rationale.
227pub struct AudienceIndex {
228    pool: sqlx::Pool<sqlx::Postgres>,
229    cache: moka::future::Cache<(IdKind, Uuid), OwnerAudience>,
230    /// The audience a never-stamped process resolves to (`MICROMEGAS_DEFAULT_AUDIENCE`),
231    /// bound into every `owner_query_sql` execution. Carried from `LakehouseContext` so this
232    /// prong and the materialization path share one resolved value.
233    default_audience: Arc<str>,
234}
235
236impl AudienceIndex {
237    pub fn new(
238        pool: sqlx::Pool<sqlx::Postgres>,
239        max_entries: u64,
240        ttl: Duration,
241        default_audience: Arc<str>,
242    ) -> Self {
243        let cache = moka::future::Cache::builder()
244            .max_capacity(max_entries)
245            .time_to_live(ttl)
246            .build();
247        Self {
248            pool,
249            cache,
250            default_audience,
251        }
252    }
253
254    /// Resolves a single id. A thin wrapper over [`Self::resolve_many`] -- always at least as
255    /// cheap, and the batch path is where the actual query lives.
256    pub async fn resolve(&self, id: Uuid, kind: IdKind) -> anyhow::Result<OwnerAudience> {
257        let mut resolved = self.resolve_many(&[id], kind).await?;
258        Ok(resolved.remove(&id).unwrap_or(OwnerAudience::Unknown))
259    }
260
261    /// Resolves a batch of ids of the same `kind` in at most one round trip (cache misses only).
262    /// `Unknown` is never cached -- a miss may just mean "not ingested yet", and caching it would
263    /// both pin a wrong answer and let a caller pollute the cache with random ids.
264    pub async fn resolve_many(
265        &self,
266        ids: &[Uuid],
267        kind: IdKind,
268    ) -> anyhow::Result<HashMap<Uuid, OwnerAudience>> {
269        let mut result = HashMap::with_capacity(ids.len());
270        let mut misses: Vec<Uuid> = Vec::new();
271        let mut seen_miss: HashSet<Uuid> = HashSet::new();
272        for &id in ids {
273            if let Some(cached) = self.cache.get(&(kind, id)).await {
274                result.insert(id, cached);
275            } else if seen_miss.insert(id) {
276                misses.push(id);
277            }
278        }
279        if misses.is_empty() {
280            return Ok(result);
281        }
282        let rows = fetch_owner_rows(&self.pool, &misses, kind, &self.default_audience).await?;
283        let resolved = merge_owner_rows(rows);
284        for id in misses {
285            let owner = resolved.get(&id).cloned().unwrap_or(OwnerAudience::Unknown);
286            if owner != OwnerAudience::Unknown {
287                self.cache.insert((kind, id), owner.clone()).await;
288            }
289            result.insert(id, owner);
290        }
291        imetric!(
292            "audience_cache_entry_count",
293            "count",
294            self.cache.entry_count()
295        );
296        Ok(result)
297    }
298}
299
300impl std::fmt::Debug for AudienceIndex {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct("AudienceIndex")
303            .field("entries", &self.cache.entry_count())
304            .finish()
305    }
306}
307
308/// The uniform, existence-oracle-proof denial text every call-level guard returns:
309/// `{fname}: '{id}' not found or not accessible`. Extracted so [`AudienceGuard::authorize`] and
310/// [`AudienceGuard::authorize_view_instance`]'s fail-closed fallthrough rule can't drift apart.
311fn not_found_err<T>(fname: &str, id: &str) -> datafusion::error::Result<T> {
312    plan_err!("{fname}: '{id}' not found or not accessible")
313}
314
315/// Pure, offline-testable: the whole authorization rule, with no I/O in it. `ReadScope::All` is
316/// the only branch that passes `Unknown` -- every other combination denies it unconditionally.
317pub fn is_readable(scope: &ReadScope, owner: &OwnerAudience) -> bool {
318    match scope {
319        ReadScope::All => true,
320        ReadScope::Audiences(auds) => match owner {
321            OwnerAudience::Unknown => false,
322            OwnerAudience::Audience(a) => auds.iter().any(|x| x.as_str() == &**a),
323            OwnerAudience::Ambiguous(owners) => {
324                !owners.is_empty() && owners.iter().all(|owner| is_readable(scope, owner))
325            }
326        },
327    }
328}
329
330/// Witness that some id's owning process was resolved and found readable under the caller's
331/// scope. Constructible only by [`AudienceGuard::authorize`] -- a future call site that wants an
332/// inner, unscoped session must hold one of these first, which is what keeps a fourth such UDTF
333/// from silently reproducing the bypass this stage closes by calling `CallerContext::internal()`
334/// directly. Not airtight: `internal()` stays public for genuinely non-user-reachable callers.
335#[derive(Debug)]
336pub struct Authorized {
337    id: Uuid,
338}
339
340impl Authorized {
341    pub fn id(&self) -> Uuid {
342        self.id
343    }
344
345    /// The `CallerContext` an inner, post-authorization session may run under. Deliberately
346    /// `ReadScope::All` (not the caller's own scope): every inner statement these three call sites run is
347    /// server-constructed and confined to `self.id`, so if `self.id`'s process is readable,
348    /// everything those statements can reach is readable too.
349    pub fn internal_caller(&self) -> CallerContext {
350        CallerContext::internal()
351    }
352}
353
354/// The whole guard: pure decision (`is_readable`) plus async resolution (`AudienceIndex`),
355/// fail-closed throughout. One instance is built per request (in `query.rs`'s
356/// `register_lakehouse_functions`) and shared, via `Arc`, across every arg-addressed UDTF/UDF the
357/// request's session registers.
358#[derive(Debug)]
359pub struct AudienceGuard {
360    read_scope: ReadScope,
361    /// Whether this deployment's caller passes the lakehouse admin gate -- `caller.is_admin`
362    /// (`query.rs`), the same boolean that already governs registration of the mutating
363    /// lakehouse UDTFs/UDFs. Consulted by [`Self::global_rows_visible`].
364    lakehouse_admin: bool,
365    public_view_sets: Vec<String>,
366    index: Arc<AudienceIndex>,
367}
368
369impl AudienceGuard {
370    pub fn new(
371        read_scope: ReadScope,
372        lakehouse_admin: bool,
373        public_view_sets: Vec<String>,
374        index: Arc<AudienceIndex>,
375    ) -> Self {
376        Self {
377            read_scope,
378            lakehouse_admin,
379            public_view_sets,
380            index,
381        }
382    }
383
384    /// The caller's scope. Exposed so callers with their own row-level filtering
385    /// (`list_partitions`) can decide up front whether to even attempt an optimization (e.g.
386    /// `LIMIT` pushdown) that's only valid under `ReadScope::All`.
387    pub fn read_scope(&self) -> &ReadScope {
388        &self.read_scope
389    }
390
391    /// `ReadScope::All` ⇒ `Ok` with no I/O at all. Otherwise resolves `id`'s owning process's
392    /// audience and denies with a uniform, existence-oracle-proof message
393    /// (`{fname}: '{id}' not found or not accessible`) on anything but a readable verdict -- a
394    /// resolution error included, never a pass. The real reason (resolved `OwnerAudience`, the
395    /// caller's scope) is logged at `debug!`, for operators only.
396    pub async fn authorize(
397        &self,
398        id: Uuid,
399        kind: IdKind,
400        fname: &str,
401    ) -> datafusion::error::Result<Authorized> {
402        if self.read_scope == ReadScope::All {
403            return Ok(Authorized { id });
404        }
405        let owner = match self.index.resolve(id, kind).await {
406            Ok(owner) => owner,
407            Err(e) => {
408                debug!("{fname}: audience resolution failed for '{id}' ({kind:?}): {e:#}");
409                return Err(DataFusionError::External(e.into()));
410            }
411        };
412        if is_readable(&self.read_scope, &owner) {
413            Ok(Authorized { id })
414        } else {
415            debug!(
416                "{fname}: denying '{id}' ({kind:?}): owner={owner:?}, scope={:?}",
417                self.read_scope
418            );
419            not_found_err(fname, &id.to_string())
420        }
421    }
422
423    /// Batch form of the same rule, for the callers that row-filter (`list_partitions`) or need
424    /// an all-or-nothing verdict over a batch (`get_payload`) rather than a single witness.
425    /// `ReadScope::All` ⇒ every id is readable, with no I/O.
426    pub async fn readable_ids(
427        &self,
428        ids: &[Uuid],
429        kind: IdKind,
430    ) -> datafusion::error::Result<HashSet<Uuid>> {
431        if self.read_scope == ReadScope::All {
432            return Ok(ids.iter().copied().collect());
433        }
434        let owners = self
435            .index
436            .resolve_many(ids, kind)
437            .await
438            .map_err(|e| DataFusionError::External(e.into()))?;
439        Ok(ids
440            .iter()
441            .copied()
442            .filter(|id| {
443                let owner = owners.get(id).unwrap_or(&OwnerAudience::Unknown);
444                is_readable(&self.read_scope, owner)
445            })
446            .collect())
447    }
448
449    /// Whether `view_set_name` is on the `MICROMEGAS_PUBLIC_VIEW_SETS` allowlist -- an operator
450    /// declaring every row of a view set readable by any authenticated caller. Shared by
451    /// [`Self::global_rows_visible`] and [`Self::authorize_view_instance`]'s rule (2) so the two
452    /// read the same allowlist through one accessor.
453    pub fn is_public_view_set(&self, view_set_name: &str) -> bool {
454        self.public_view_sets.iter().any(|s| s == view_set_name)
455    }
456
457    /// `list_partitions`' `'global'`-row rule: a global partition is a multi-audience
458    /// file -- it has no single owning audience to check against the caller's scope. Visible
459    /// under `ReadScope::All`, when `view_set_name` is on the public allowlist, or when the
460    /// caller passes the lakehouse admin gate (the same boolean that already governs the
461    /// mutating UDTFs/UDFs: a caller who can `retire_partitions`/`regenerate_partitions` a global
462    /// file can see it -- no new authority, no new knob).
463    pub fn global_rows_visible(&self, view_set_name: &str) -> bool {
464        match &self.read_scope {
465            ReadScope::All => true,
466            ReadScope::Audiences(_) => {
467                self.is_public_view_set(view_set_name) || self.lakehouse_admin
468            }
469        }
470    }
471
472    /// The `view_instance(view_set_name, view_instance_id)` scan-time check, run by
473    /// `MaterializedView::scan` before `jit_update` -- so a caller scoped to one audience cannot
474    /// trigger JIT materialization of an instance it cannot read.
475    ///
476    /// Rules, in order:
477    ///
478    /// 1. `ReadScope::All` -> `Ok(())`, no I/O. Internal, maintenance, `--disable-auth`, and
479    ///    every inner session built from an `Authorized::internal_caller()` take this arm.
480    /// 2. `view_set_name` on `public_view_sets` -> `Ok(())`. Matches the row-level filter: an operator who
481    ///    declared a view set public has said every row of it is readable, so denying
482    ///    materialization of one of its instances would be incoherent. Confidentiality-only:
483    ///    this leaves the cost/availability residual this method exists to close fully open for
484    ///    any view set an operator puts on the allowlist -- any authenticated caller can still
485    ///    force `jit_update` for arbitrary process/stream ids of a public view set. Deliberate.
486    /// 3. `view_instance_id == "global"` -> `Ok(())`. Global instances are row-filtered, not
487    ///    call-guarded: `jit_update` is a no-op for every `'global'` instance today
488    ///    (`LogView`/`MetricsView`, the only two view sets that accept `'global'`), so there is
489    ///    no materialization to protect against, and denying the call would break the legal
490    ///    `view_instance('log_entries', 'global')` spelling of `SELECT * FROM log_entries` for no
491    ///    security gain -- the row-level filter already filters its rows one at a time. This is a different
492    ///    rule from `list_partitions`' `global_rows_visible`, which gates *visibility of
493    ///    partition metadata* about a `'global'` file with no per-row filter available; reusing
494    ///    it here would deny the `log_entries`/`measures` `'global'` instances to every
495    ///    non-admin scoped caller, a regression. The invariant that makes this rule sound --
496    ///    `'global'` never triggers JIT -- lives in each view set's `jit_update` impl; a future
497    ///    view set whose `'global'` instance does materialize would need to revisit this rule.
498    /// 4. `view_instance_id` parses as a `Uuid` -> [`Self::authorize`] under
499    ///    `IdKind::ProcessOrStream`, discarding the returned `Authorized`. The witness exists to
500    ///    gate construction of an inner unscoped session; this call site builds none --
501    ///    `jit_update`'s `CallerContext::internal()` is unchanged and stays where it is.
502    /// 5. anything else -> the same uniform denial as (4). Fail-closed, matching
503    ///    `list_partitions`'s "anything else is dropped" rule. Unreachable for today's view sets
504    ///    (every `make_view` either accepts `'global'` or `Uuid::parse_str`s, failing at plan
505    ///    time otherwise), so this is a safety net against a future view set with a different id
506    ///    vocabulary.
507    pub async fn authorize_view_instance(
508        &self,
509        view_set_name: &str,
510        view_instance_id: &str,
511    ) -> datafusion::error::Result<()> {
512        if self.read_scope == ReadScope::All {
513            return Ok(());
514        }
515        if self.is_public_view_set(view_set_name) {
516            return Ok(());
517        }
518        if view_instance_id == "global" {
519            return Ok(());
520        }
521        if let Ok(id) = Uuid::parse_str(view_instance_id) {
522            self.authorize(id, IdKind::ProcessOrStream, "view_instance")
523                .await?;
524            return Ok(());
525        }
526        not_found_err("view_instance", view_instance_id)
527    }
528}