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}