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}