Skip to main content

micromegas_analytics/lakehouse/
read_scope.rs

1//! The analytics-side half of the authorization seam.
2//!
3//! `ReadScope` is the query planner's authorization input -- what a `ReadPolicy`
4//! (`micromegas-auth`) resolves down to. `micromegas-analytics` does not depend on
5//! `micromegas-auth`: that edge would pull in the whole OIDC/JWT dependency tree behind one enum,
6//! so `ReadScope` carries resolved audiences only -- no group vocabulary, no policy trait, nothing
7//! that requires a store. The `rust/public` bridge is the only place a `ReadableAudiences` (from
8//! `micromegas-auth`) becomes a `ReadScope`.
9//!
10//! Enforcement has two layers. [`crate::lakehouse::ownership_rewrite::OwnershipRewrite`] (the row-level filter) reads
11//! `ReadScope` out of [`crate::lakehouse::read_scope::CallerContext`] inside `query.rs::make_session_context` and injects an
12//! audience predicate into every `MaterializedView`-backed scan. The call-level guard is the UDTF/UDF guards
13//! ([`crate::lakehouse::audience_guard::AudienceGuard`]) for the span/metadata functions the row-level filter structurally
14//! cannot reach, plus `CallerContext.is_admin`'s mutating-function registration
15//! gate. `view_instance(...)` is also guarded by the call-level guard, closing a cost/availability residual for
16//! the six view sets carrying a physical `audience` column, where the row-level filter already filters
17//! `view_instance` scans row-by-row, same as the named-table form; for the other five view sets,
18//! reachable only through a guarded `view_instance(...)`, the call-level guard is the sole enforcement
19//! -- the row-level filter injects no predicate there at all.
20
21use std::sync::Arc;
22
23/// The authorization scope a query planner may read under.
24///
25/// `All` is deliberately not something a `ReadPolicy` can produce (see
26/// `micromegas_auth::policy::ReadPolicy`'s doc comment) -- it is the marker internal
27/// (non-request) callers pass, via [`CallerContext::internal`] / [`CallerContext::maintenance`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ReadScope {
30    /// No audience restriction. Only ever constructed by an internal/maintenance caller, or by
31    /// the `rust/public` resolver when no `AuthContext` extension is present at all (no auth
32    /// provider configured, e.g. `--disable-auth`) -- never as the output of resolving a
33    /// `ReadPolicy` against a real caller.
34    All,
35    /// Restricted to the given set of audiences -- opaque labels (e.g. `"public"`,
36    /// `"team-alpha"`), as resolved by a `ReadPolicy`.
37    Audiences(Arc<[String]>),
38}
39
40/// Bundles the orthogonal authorization inputs `make_session_context` and friends need --
41/// audience scope (`read_scope`) and the `is_admin` mutating-function-registration capability --
42/// into one struct instead of adjacent, transposable positional parameters.
43///
44/// Required (not `Option`/defaulted) at every call site by design: a defaulting parameter would
45/// let a future call site inherit `ReadScope::All` by omission, which is exactly the failure this
46/// seam exists to prevent. Every call site must state its scope explicitly, via one of the three
47/// constructors below or a resolved value from a `ReadPolicy`.
48#[derive(Debug, Clone)]
49pub struct CallerContext {
50    /// The audience scope to plan queries under. Consumed by
51    /// [`super::ownership_rewrite::OwnershipRewrite`] inside `query.rs::make_session_context`.
52    pub read_scope: ReadScope,
53    /// Whether the caller may use the five mutating lakehouse UDTFs/UDFs.
54    pub is_admin: bool,
55    /// Per-service data-isolation deployment config (`MICROMEGAS_PUBLIC_VIEW_SETS`) -- resolved
56    /// once at server startup, not per request, but bundled here rather than as a new
57    /// `make_session_context` parameter: per-request resolved values ride the context,
58    /// per-service objects live on the service, and this rides along with `read_scope` at every
59    /// real call site anyway.
60    pub isolation_config: Arc<IsolationConfig>,
61    /// The caller's identity, as recorded in `deny_queries`'s `created_by` column. `None` on
62    /// internal/maintenance paths -- such a caller cannot call `deny_queries`, which requires
63    /// `Some`. One string, not a struct: `created_by` is the only consumer, so a richer identity
64    /// type would be written at every construction site and read nowhere.
65    pub identity: Option<String>,
66    /// The grant selectors this caller matches -- `"*"`, `"user:<email>"` when an email is present,
67    /// and one `"group:<g>"` per claimed group -- precomputed by `rust/public` from the
68    /// `AuthContext` (`micromegas_auth::policy::caller_selectors`), so `micromegas-analytics` never
69    /// needs the auth crate. Empty for internal and maintenance callers and for a request with no
70    /// `AuthContext` at all (`--disable-auth`). Consumed by `list_audience_grants()`
71    /// (`list_audience_grants_table_function.rs`); admins do not need it -- they see every row.
72    pub grant_selectors: Arc<[String]>,
73}
74
75impl CallerContext {
76    /// For background/materialization callers that are not serving a user request at all
77    /// (`is_admin: false`, `ReadScope::All`). Distinct from [`Self::maintenance`] only in
78    /// `is_admin` -- use this for internal call sites that must not register the mutating
79    /// UDTFs/UDFs.
80    pub fn internal() -> Self {
81        Self {
82            read_scope: ReadScope::All,
83            is_admin: false,
84            isolation_config: Arc::new(IsolationConfig::default()),
85            identity: None,
86            grant_selectors: Arc::from([]),
87        }
88    }
89
90    /// For background/materialization callers performing maintenance work (`is_admin: true`,
91    /// `ReadScope::All`) -- never a user session.
92    pub fn maintenance() -> Self {
93        Self {
94            read_scope: ReadScope::All,
95            is_admin: true,
96            isolation_config: Arc::new(IsolationConfig::default()),
97            identity: None,
98            grant_selectors: Arc::from([]),
99        }
100    }
101}
102
103/// Deployment config for the data-isolation seam: [`super::ownership_rewrite::OwnershipRewrite`]
104/// (the row-level filter). Resolved once at server startup from environment variables -- see
105/// [`IsolationConfig::from_env`].
106///
107/// `#[derive(Default)]`: `public_view_sets` is the only field, and its empty-by-default matches
108/// [`Self::from_env`]'s own default.
109#[derive(Debug, Clone, Default)]
110pub struct IsolationConfig {
111    /// View-set names `OwnershipRewrite` skips entirely -- no predicate injected at all,
112    /// regardless of scope. Off (empty) by default, fail-closed for this
113    /// operator-responsibility allowlist. Parsed from `MICROMEGAS_PUBLIC_VIEW_SETS`.
114    pub public_view_sets: Vec<String>,
115}
116
117/// Comma-separated list parser for `MICROMEGAS_PUBLIC_VIEW_SETS`.
118///
119/// Deliberately a comma-separated list (rejecting `[`, `]`, `"`) rather than the
120/// `MICROMEGAS_API_KEYS` JSON-array shape -- duplicated here rather than depending on
121/// `micromegas-auth` for it, since `micromegas-analytics` does not depend on that crate (the same
122/// crate-boundary reasoning `read_scope.rs`'s own module doc comment gives for keeping
123/// `ReadScope` here).
124fn parse_comma_separated_list(var: &str) -> anyhow::Result<Vec<String>> {
125    let raw = match std::env::var(var) {
126        Ok(raw) => raw,
127        Err(_) => return Ok(vec![]),
128    };
129    if raw.trim().is_empty() {
130        return Ok(vec![]);
131    }
132    let mut values = Vec::new();
133    for entry in raw.split(',') {
134        let trimmed = entry.trim();
135        if trimmed.is_empty() {
136            anyhow::bail!("{var}: empty entry (entry {entry:?} in {raw:?})");
137        }
138        if trimmed.contains(['[', ']', '"']) {
139            anyhow::bail!(
140                "{var}: entry {trimmed:?} contains '[', ']', or '\"' -- this variable is \
141                 comma-separated, not a JSON array like MICROMEGAS_API_KEYS"
142            );
143        }
144        values.push(trimmed.to_string());
145    }
146    Ok(values)
147}
148
149impl IsolationConfig {
150    /// Resolves the surviving knob from the environment. `MICROMEGAS_PUBLIC_VIEW_SETS` unset ⇒
151    /// no public view sets; a malformed entry is `Err`, not silently ignored -- a startup `?`
152    /// turns a typo into a fail-fast instead of a silently-inert knob.
153    pub fn from_env() -> anyhow::Result<Self> {
154        Ok(Self {
155            public_view_sets: parse_comma_separated_list("MICROMEGAS_PUBLIC_VIEW_SETS")?,
156        })
157    }
158}