Skip to main content

micromegas_auth/
policy.rs

1//! Authorization seam: `MintPolicy`, `ReadPolicy`, and the audience-based implementations that
2//! resolve them from a caller's `AuthContext` and a static grant map keyed by audience name.
3//!
4//! **No enforcement lands with this module itself.** This module fixes the *shape* of
5//! authorization -- every caller of these traits must deny on `Err`, and `ReadPolicy` cannot
6//! express "grant everything" at all -- while the resolved `ReadableAudiences`/`ReadScope` is
7//! consumed downstream by `OwnershipRewrite` (the row-level filter) and the call-level guard's UDTF/UDF guards. See
8//! `rust/analytics/src/lakehouse/read_scope.rs`.
9//!
10//! **An audience is an opaque label, not a principal encoding.** `is_valid_audience` and
11//! `PUBLIC_AUDIENCE` define what a name is; `AudienceGrants` defines who may read or mint into
12//! it. No code here derives an audience from a caller's identity -- see `AudienceGrants` for the
13//! reasoning.
14
15use crate::db_audience_grants::DbAudienceGrantsSource;
16use crate::env::resolve_prefixed_var;
17use crate::types::AuthContext;
18use anyhow::{Result, anyhow};
19use async_trait::async_trait;
20use micromegas_tracing::{info, warn};
21use serde::Deserialize;
22use serde::de::{self, MapAccess, Visitor};
23use std::collections::BTreeMap;
24use std::collections::BTreeSet;
25use std::fmt;
26use std::fmt::Debug;
27use std::sync::Arc;
28
29/// The conventional shared audience. Not a built-in read grant -- every-principal readability
30/// comes from the seeded `('public', 'read', '*')` row (schema v9), an ordinary grant like any
31/// other, removable the same way.
32pub const PUBLIC_AUDIENCE: &str = "public";
33
34/// `true` if `aud` is a valid audience name: `[A-Za-z0-9_-]{1,255}`, checked in bytes -- the
35/// ASCII-only charset makes byte length and char length agree for every value that can pass, so
36/// this doubles as the length check the `ingestion_api_keys.audience` `VARCHAR(255)` column
37/// enforces on the SQL side.
38///
39/// An audience is an opaque label on data, not a principal encoding: this charset makes an email
40/// (`@`, `.`) or a `user:`/`group:`-prefixed value (`:`) unrepresentable, on purpose -- see
41/// `AudienceGrants` for why there is no derivation from a caller's identity to replace it with.
42///
43/// Deliberately **not** normalizing: no case folding, no trimming. The value is stored and
44/// compared verbatim, which is what makes uniqueness meaningful -- `team-alpha` and `Team-Alpha`
45/// are two audiences, and neither is a typo the system silently repairs into the other.
46pub fn is_valid_audience(aud: &str) -> bool {
47    !aud.is_empty()
48        && aud.len() <= 255
49        && aud
50            .bytes()
51            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
52}
53
54/// Resolves `{prefix}_DEFAULT_AUDIENCE` (falling back to `MICROMEGAS_DEFAULT_AUDIENCE`) --
55/// **the** deployment default audience, defaulting to [`PUBLIC_AUDIENCE`] when unset. Resolved
56/// **once at startup** (`web_server.rs`) so a typo fails fast rather than surfacing as a
57/// per-request 400. Empty or otherwise invalid ⇒ `Err`: with one knob there is no "unset" state
58/// left for an empty string to mean, so a blank value is a misconfiguration, not an opt-out.
59///
60/// Leading/trailing whitespace is trimmed and `warn!`-logged, the same way the other two readers
61/// of this same variable handle it, so one env value can never be accepted by one role and
62/// rejected by another. Trimming keeps a value a deployment template rendered with stray
63/// padding working; the warning stays because padding is far more likely a templating slip than
64/// an intention.
65///
66/// One knob, one meaning: the audience anything that arrives without one gets. There are three
67/// code readers today (not deployment roles -- a role can build more than one of these):
68/// this function (key mint), `micromegas_analytics::audience::default_audience_from_env`
69/// (read once by `LakehouseContext::new` and handed to the three Postgres read sites), and the
70/// ingestion HTTP edge's own call in `serve_ingestion`, which resolves the default a credential
71/// with no bound audience is stamped with at write time. This is what
72/// `ingestion_keys.rs::resolve_audience` falls back to on the `mint` route.
73pub fn default_audience_from_env(prefix: &str) -> Result<String> {
74    let var = resolve_prefixed_var(prefix, "DEFAULT_AUDIENCE");
75    let resolved = match std::env::var(&var) {
76        Ok(raw) => {
77            let trimmed = raw.trim();
78            if trimmed != raw {
79                warn!(
80                    "{var}: value {raw:?} has leading or trailing whitespace -- using {trimmed:?}"
81                );
82            }
83            let raw = trimmed.to_string();
84            if is_valid_audience(&raw) {
85                raw
86            } else {
87                return Err(anyhow!(
88                    "{var}: {raw:?} is not a valid audience name -- must match [A-Za-z0-9_-]{{1,255}}"
89                ));
90            }
91        }
92        Err(_) => PUBLIC_AUDIENCE.to_owned(),
93    };
94    info!("{var}: default audience = {resolved}");
95    Ok(resolved)
96}
97
98/// A principal selector on either axis of an [`AudienceGrants`] entry: `*` (any authenticated
99/// principal), `user:<email>` (matches `AuthContext.email`), or `group:<g>` (matches any entry in
100/// `AuthContext.memberships` -- the caller's resolved, transitive local-group closure). Validated
101/// at parse time; `resolve`/`resolve_audience` never see an unrecognized shape.
102///
103/// `pub` -- `analytics-web-srv`'s admin grant-write route (a separate crate) needs to run this
104/// exact same selector-shape check `parse`/`from_rows` run, not a re-implementation of it.
105pub fn valid_selector(selector: &str) -> bool {
106    selector == "*"
107        || selector
108            .strip_prefix("user:")
109            .is_some_and(|rest| !rest.is_empty())
110        || selector
111            .strip_prefix("group:")
112            .is_some_and(|rest| !rest.is_empty())
113}
114
115/// The grant selectors `caller` matches: a leading `"*"` (every caller matches it), then
116/// `"user:<email>"` when `caller.email` is `Some`, then one `"group:<g>"` per entry in
117/// `caller.memberships`. `pub` -- shared by three consumers: `flight_sql_service_impl.rs`'s
118/// `CallerContext::grant_selectors`, `analytics-web-srv`'s `my_audiences` handler, and the
119/// audience-grant write policy's per-pair hold check (which must strip the leading `"*"` before
120/// binding the result -- a `*` grant is not something a caller individually holds).
121pub fn caller_selectors(caller: &AuthContext) -> Vec<String> {
122    let mut selectors = vec!["*".to_string()];
123    if let Some(email) = &caller.email {
124        selectors.push(format!("user:{email}"));
125    }
126    selectors.extend(caller.memberships.iter().map(|g| format!("group:{g}")));
127    selectors
128}
129
130fn selector_matches(selector: &str, caller: &AuthContext) -> bool {
131    if selector == "*" {
132        return true;
133    }
134    if let Some(email) = selector.strip_prefix("user:") {
135        return caller.email.as_deref() == Some(email);
136    }
137    if let Some(group) = selector.strip_prefix("group:") {
138        return caller.memberships.iter().any(|g| g == group);
139    }
140    // Unreachable given `valid_selector` gates every selector at parse time; false rather than
141    // panicking keeps this fail-closed if that invariant is ever violated.
142    false
143}
144
145/// One audience's read/mint selector lists, both already validated by [`AudienceGrants::parse`].
146#[derive(Debug, Clone, Default)]
147struct GrantEntry {
148    read: Vec<String>,
149    mint: Vec<String>,
150}
151
152/// The Rust side of `audience_grants.axis` -- which selector list a DB row
153/// contributes to, mirroring the JSON grant map's `"read"`/`"mint"` keys.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum GrantAxis {
156    Read,
157    Mint,
158}
159
160/// The bare-array-or-object shape a single grant-map value may take on the wire, before content
161/// validation. An untagged enum rather than a hand-rolled visitor: JSON arrays and objects are
162/// syntactically distinct, so serde can dispatch on shape alone, and any value that is neither
163/// (a number, a string, `null`, ...) fails here with serde's own type-mismatch error.
164#[derive(Debug, Deserialize)]
165#[serde(untagged)]
166enum RawGrantValue {
167    /// Read-only shorthand: a bare array is the audience's `read` list, with an implicitly empty
168    /// `mint` list -- never derived from `read`.
169    Bare(Vec<String>),
170    /// The explicit form, needed only when an audience also grants mint authority.
171    Object(RawGrantObject),
172}
173
174/// The object form of a single grant-map value, broken out of [`RawGrantValue::Object`] so
175/// `#[serde(deny_unknown_fields)]` -- which serde cannot apply directly to an enum variant --
176/// has somewhere to attach: without it, a misspelled key (`"raed"` for `"read"`) would silently
177/// parse into an empty grant instead of failing startup.
178#[derive(Debug, Deserialize)]
179#[serde(deny_unknown_fields)]
180struct RawGrantObject {
181    #[serde(default)]
182    read: Vec<String>,
183    #[serde(default)]
184    mint: Vec<String>,
185}
186
187/// Deserializes a grant-map JSON document while rejecting a repeated key -- `serde_json`'s own
188/// `Map` deserialization silently keeps the *last* value for a duplicate key, which would discard
189/// an earlier grant list without a word (see `AudienceGrants`'s doc comment). Pulling keys one at
190/// a time through `MapAccess`, rather than deserializing straight into a `BTreeMap`, is what makes
191/// that duplicate visible before it is lost.
192struct RawAudienceGrants(BTreeMap<String, RawGrantValue>);
193
194impl<'de> Deserialize<'de> for RawAudienceGrants {
195    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
196    where
197        D: serde::Deserializer<'de>,
198    {
199        struct GrantsVisitor;
200
201        impl<'de> Visitor<'de> for GrantsVisitor {
202            type Value = RawAudienceGrants;
203
204            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
205                write!(f, "a JSON object keyed by audience name")
206            }
207
208            fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
209            where
210                A: MapAccess<'de>,
211            {
212                let mut result = BTreeMap::new();
213                while let Some(key) = map.next_key::<String>()? {
214                    let value: RawGrantValue = map.next_value()?;
215                    if result.contains_key(&key) {
216                        return Err(de::Error::custom(format!(
217                            "duplicate audience key {key:?} in grant map"
218                        )));
219                    }
220                    result.insert(key, value);
221                }
222                Ok(RawAudienceGrants(result))
223            }
224        }
225
226        deserializer.deserialize_map(GrantsVisitor)
227    }
228}
229
230/// The parsed, validated grant map: "who can access this audience", keyed by audience name, one
231/// relation per axis (read/mint -- AbAC plan's `group_read_grants` / `group_mint_grants`, kept as
232/// one static map here since the DB store (`DbAudienceGrantsSource`) is checked as a separate
233/// source, not merged into this one).
234///
235/// A bare-array value is read-only shorthand: an omitted `"mint"` list is therefore always empty,
236/// never defaulted from `"read"` -- a read grant confers no mint authority, by construction.
237///
238/// `public` is stored the same way as any other audience -- via a `{"public": ["*"]}` entry here,
239/// or, in production, the seeded `('public', 'read', '*')` DB row (schema v9). It has no built-in
240/// grant on either axis; nothing here or in `AudienceReadPolicy::resolve` treats it specially.
241#[derive(Debug, Clone, Default)]
242pub struct AudienceGrants {
243    entries: BTreeMap<String, GrantEntry>,
244}
245
246impl AudienceGrants {
247    /// No grants at all -- every audience, `public` included, is unreadable and unmintable by
248    /// anyone non-admin. Used as the disabled-auth / no-explicit-policy default.
249    pub fn empty() -> Self {
250        Self::default()
251    }
252
253    /// Parses a grant-map JSON document directly -- the documented published format for a static
254    /// grant map, kept as a test-only entry point now that no production caller builds a policy
255    /// from raw JSON (`mint_key` builds one from [`Self::from_rows`] against a live query
256    /// instead).
257    ///
258    /// Validates both axes of the map, not just its JSON shape -- same reason every other knob in
259    /// this crate fails startup on a typo rather than shipping inert: every key must satisfy
260    /// [`is_valid_audience`] (`{"group:everyone": [...]}`, the value operators are migrating
261    /// *from*, can never match a stamped audience once the charset excludes `:`), and every
262    /// selector on either axis must be `*`, `user:<non-empty>`, or `group:<non-empty>` (an
263    /// unprefixed or mis-prefixed selector matches no identity axis and would otherwise be a
264    /// silently-inert entry).
265    pub fn parse(raw: &str) -> Result<Self> {
266        let raw: RawAudienceGrants =
267            serde_json::from_str(raw).map_err(|e| anyhow!("invalid audience grant map: {e}"))?;
268        let mut entries = BTreeMap::new();
269        for (audience, value) in raw.0 {
270            if !is_valid_audience(&audience) {
271                return Err(anyhow!(
272                    "invalid audience grant map: {audience:?} is not a valid audience name -- \
273                     must match [A-Za-z0-9_-]{{1,255}}"
274                ));
275            }
276            let (read, mint) = match value {
277                RawGrantValue::Bare(read) => (read, vec![]),
278                RawGrantValue::Object(RawGrantObject { read, mint }) => (read, mint),
279            };
280            for selector in read.iter().chain(mint.iter()) {
281                if !valid_selector(selector) {
282                    return Err(anyhow!(
283                        "invalid audience grant map: selector {selector:?} for audience \
284                         {audience:?} must be '*', 'user:<id>', or 'group:<id>'"
285                    ));
286                }
287            }
288            entries.insert(audience, GrantEntry { read, mint });
289        }
290        Ok(Self { entries })
291    }
292
293    /// Builds an `AudienceGrants` from `(audience, axis, selector)` triples -- the DB-store
294    /// analogue of [`Self::parse`]. Runs the *same* [`is_valid_audience`]/[`valid_selector`]
295    /// checks `parse` runs, so a row that slipped past `audience_grants`'s own `CHECK`
296    /// constraints (e.g. via a direct `psql` session) still can't reach a policy decision: this
297    /// is the one place both the JSON path and the DB path fail closed on a malformed row.
298    pub fn from_rows(rows: impl IntoIterator<Item = (String, GrantAxis, String)>) -> Result<Self> {
299        let mut entries: BTreeMap<String, GrantEntry> = BTreeMap::new();
300        for (audience, axis, selector) in rows {
301            if !is_valid_audience(&audience) {
302                return Err(anyhow!(
303                    "invalid audience grant row: {audience:?} is not a valid audience name -- \
304                     must match [A-Za-z0-9_-]{{1,255}}"
305                ));
306            }
307            if !valid_selector(&selector) {
308                return Err(anyhow!(
309                    "invalid audience grant row: selector {selector:?} for audience \
310                     {audience:?} must be '*', 'user:<id>', or 'group:<id>'"
311                ));
312            }
313            let entry = entries.entry(audience).or_default();
314            match axis {
315                GrantAxis::Read => entry.read.push(selector),
316                GrantAxis::Mint => entry.mint.push(selector),
317            }
318        }
319        Ok(Self { entries })
320    }
321
322    /// Unions each audience's `read`/`mint` selector lists across `self` and `other`: a selector
323    /// present in either map grants exactly the same access as being present in both. No dedup
324    /// -- `selector_matches` is called with `.any()`, so a selector present in both sources costs
325    /// one redundant comparison, never a wrong answer.
326    ///
327    /// A public utility for combining two `AudienceGrants` maps, exercised by this crate's own
328    /// integration tests (`rust/auth/tests/policy_tests.rs`); `resolve`/`resolve_audience` do
329    /// **not** call it -- they check the static map and the DB store snapshot as two separate
330    /// sources instead, specifically to avoid a per-request deep clone (see the comment in
331    /// [`AudienceReadPolicy::resolve`]).
332    pub fn merge(&self, other: &Self) -> Self {
333        let mut entries = self.entries.clone();
334        for (audience, other_entry) in &other.entries {
335            let entry = entries.entry(audience.clone()).or_default();
336            entry.read.extend(other_entry.read.iter().cloned());
337            entry.mint.extend(other_entry.mint.iter().cloned());
338        }
339        Self { entries }
340    }
341
342    fn readers(&self) -> impl Iterator<Item = (&String, &[String])> {
343        self.entries.iter().map(|(a, e)| (a, e.read.as_slice()))
344    }
345
346    fn mint_selectors(&self, audience: &str) -> &[String] {
347        self.entries
348            .get(audience)
349            .map(|e| e.mint.as_slice())
350            .unwrap_or(&[])
351    }
352}
353
354/// A policy's resolved set of audiences a caller may read.
355///
356/// Newtype over `Arc<[String]>`, not a bare `Vec<String>`/`Arc<[String]>` -- so a policy's
357/// result can never be confused with any other string list on a security path.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ReadableAudiences(Arc<[String]>);
360
361impl ReadableAudiences {
362    /// Wraps an already-computed audience set.
363    pub fn new(audiences: Arc<[String]>) -> Self {
364        Self(audiences)
365    }
366
367    /// Unwraps the audience set. This is the one cross-crate conversion point: the
368    /// `rust/public` bridge calls this to build
369    /// `micromegas_analytics::lakehouse::read_scope::ReadScope::Audiences`.
370    pub fn into_inner(self) -> Arc<[String]> {
371        self.0
372    }
373}
374
375/// Resolves the set of audiences an authenticated caller may read.
376///
377/// `resolve` can **never** return "all" -- `ReadScope::All` is deliberately not a policy output.
378/// It is the marker only internal (non-request) callers pass, which is what keeps "who granted
379/// themselves `All`?" a greppable question with a small, auditable answer set. A policy that
380/// wants to be maximally permissive still has to enumerate the audiences that make it so.
381///
382/// `async` and fallible because grants can be resolved from a store -- nested groups, plus
383/// group→audience grants -- which cannot live behind a sync, infallible signature.
384/// `AudienceReadPolicy` is fallible today whenever a [`DbAudienceGrantsSource`] is attached via
385/// `with_store` -- a cold-start store outage with no prior successful load returns `Err`, which
386/// `caller_context` (`flight_sql_service_impl.rs`) maps to `Status::unavailable`. Without a store
387/// attached it still cannot fail.
388///
389/// **Every caller must deny on `Err`.** An `Err` here means "the policy could not be evaluated" --
390/// never soften it into `ReadableAudiences::new(Arc::from([]))` (that would read as a legitimate,
391/// audited fail-closed decision) and never into `ReadScope::All` (a silent fail-open bypass).
392#[async_trait]
393pub trait ReadPolicy: Send + Sync + Debug {
394    /// Resolves `caller`'s readable-audience set, or `Err` if the policy could not be evaluated
395    /// (e.g. a backing store outage once a store-backed policy lands).
396    async fn resolve(&self, caller: &AuthContext) -> Result<ReadableAudiences>;
397}
398
399/// Resolves the audience a newly minted key may be stamped with.
400///
401/// `requested` is the caller-supplied audience (e.g. `"team-alpha"`); `None` means "no audience
402/// requested and none can be defaulted" -- under the opaque-label model there is no "myself"
403/// audience for a caller's identity to default to, so every implementation must return `Err` for
404/// `requested: None`, admin or not. Async and fallible for the same reason as `ReadPolicy` --
405/// wired by `mint_key` (`analytics-web-srv/src/ingestion_keys.rs`), gated by
406/// `MintGate`/`AuthenticatedUser` rather than `AdminUser`.
407#[async_trait]
408pub trait MintPolicy: Send + Sync + Debug {
409    /// Resolves the audience to stamp on a newly minted key, or `Err` if `requested` is outside
410    /// what `caller` may mint (including `requested: None` -- see the trait doc comment).
411    async fn resolve_audience(
412        &self,
413        caller: &AuthContext,
414        requested: Option<&str>,
415    ) -> Result<String>;
416}
417
418/// The shipped `ReadPolicy`: a lookup over an [`AudienceGrants`] map -- and, when a
419/// [`DbAudienceGrantsSource`] is attached via `with_store`, a second lookup over that store's
420/// snapshot -- with no identity derivation anywhere. Resolves the readable set:
421///
422/// ```text
423/// { a : "*"            ∈ grants[a].read }
424///   ∪ { a : "user:<email>" ∈ grants[a].read }                  if email present
425///   ∪ { a : "group:<g>"    ∈ grants[a].read for some g ∈ caller.memberships }
426///   ∪ { a : selector       ∈ store.readers(a) matches caller } if a store is attached
427///   ∪ caller.read_audiences                                    // per-key direct grant
428/// ```
429///
430/// There is **no self-audience rule** -- a caller is granted no audience merely for being named
431/// like one: the charset makes an email unrepresentable as an audience name, and keying on
432/// `subject` instead would let an admin mint themselves read access by naming a key after an
433/// audience.
434///
435/// `read_audiences` is folded in here but deliberately **excluded** from
436/// `AudienceMintPolicy`'s mintable set -- a service-account key's ability to *see* an audience
437/// must not imply it can *stamp new keys* with that audience (mint is integrity, read is
438/// confidentiality).
439#[derive(Debug, Clone, Default)]
440pub struct AudienceReadPolicy {
441    grants: AudienceGrants,
442    /// The DB-backed grant store, when this process has one configured -- checked alongside
443    /// `grants` on every `resolve` call, as a separate source rather than merged into it (see
444    /// `resolve`). `None` for every disabled-auth/test caller that has no DB pool to back one.
445    store: Option<Arc<DbAudienceGrantsSource>>,
446}
447
448impl AudienceReadPolicy {
449    /// Builds a policy with an explicit static grant map.
450    pub fn new(grants: AudienceGrants) -> Self {
451        Self {
452            grants,
453            store: None,
454        }
455    }
456
457    /// Attaches the DB-backed grant store. A builder method, not a constructor argument, so
458    /// `new`/`default` keep working unchanged for every caller with no DB pool to back one
459    /// (disabled-auth, tests).
460    pub fn with_store(mut self, store: Arc<DbAudienceGrantsSource>) -> Self {
461        self.store = Some(store);
462        self
463    }
464}
465
466#[async_trait]
467impl ReadPolicy for AudienceReadPolicy {
468    async fn resolve(&self, caller: &AuthContext) -> Result<ReadableAudiences> {
469        // `Err` only on a cold-start outage (no snapshot has ever loaded successfully) --
470        // propagated as-is, so `resolve` denies exactly as documented on its own trait.
471        let store_grants = match &self.store {
472            Some(store) => Some(store.current().await?),
473            None => None,
474        };
475        let mut set = BTreeSet::new();
476        // The static map and the store snapshot are checked as two separate sources -- a
477        // selector present in either grants access -- rather than merged into one map, so
478        // neither side is deep-cloned on every request.
479        for (audience, read) in self.grants.readers() {
480            if read.iter().any(|s| selector_matches(s, caller)) {
481                set.insert(audience.clone());
482            }
483        }
484        if let Some(grants) = &store_grants {
485            for (audience, read) in grants.readers() {
486                if read.iter().any(|s| selector_matches(s, caller)) {
487                    set.insert(audience.clone());
488                }
489            }
490        }
491        for audience in &caller.read_audiences {
492            set.insert(audience.clone());
493        }
494        let audiences: Arc<[String]> = set.into_iter().collect::<Vec<_>>().into();
495        Ok(ReadableAudiences::new(audiences))
496    }
497}
498
499/// The shipped `MintPolicy`. Every caller, admin included, may mint only an audience in their
500/// **mint** set -- `grants[a].mint`, never `grants[a].read` (a read grant confers no mint
501/// authority, unchanged from `AudienceReadPolicy`'s split); being able to *read* `public` via its
502/// seeded read grant does not imply being able to *mint into* it -- that requires its own grant
503/// naming `public` in a `"mint"` list (in production, the seeded `('public', 'mint', '*')` row,
504/// schema v9).
505///
506/// A route's authorization is the authority that route's own effect requires: minting yields
507/// write access to an audience, so it requires a grant on that audience -- `is_admin` confers
508/// none. `mint_key` (`analytics-web-srv/src/ingestion_keys.rs`) delegates authorization to this
509/// policy for every caller, with no admin-specific arm of its own accord (its own gate,
510/// `MintGate`, only enforces the self-service knob against non-admins).
511#[derive(Debug, Clone, Default)]
512pub struct AudienceMintPolicy {
513    grants: AudienceGrants,
514    /// The DB-backed grant store. Built for symmetry with [`AudienceReadPolicy::with_store`] and
515    /// unit-tested, but nothing wires it in production yet: no production code constructs a `dyn
516    /// MintPolicy` today -- see [`with_store`](Self::with_store).
517    store: Option<Arc<DbAudienceGrantsSource>>,
518}
519
520impl AudienceMintPolicy {
521    /// Builds a policy with an explicit static grant map.
522    pub fn new(grants: AudienceGrants) -> Self {
523        Self {
524            grants,
525            store: None,
526        }
527    }
528
529    /// Attaches the DB-backed grant store. Built alongside [`AudienceReadPolicy::with_store`] for
530    /// symmetry -- unlike the read side, no production call site attaches a store through this
531    /// method: `mint_key` constructs its `AudienceMintPolicy` via `new`, so mint grants stay
532    /// resolved by a fresh, uncached point query against `audience_grants`, never a
533    /// `DbAudienceGrantsSource` snapshot.
534    pub fn with_store(mut self, store: Arc<DbAudienceGrantsSource>) -> Self {
535        self.store = Some(store);
536        self
537    }
538}
539
540#[async_trait]
541impl MintPolicy for AudienceMintPolicy {
542    async fn resolve_audience(
543        &self,
544        caller: &AuthContext,
545        requested: Option<&str>,
546    ) -> Result<String> {
547        let Some(aud) = requested else {
548            return Err(anyhow!("no audience requested and none can be defaulted"));
549        };
550        if !is_valid_audience(aud) {
551            return Err(anyhow!(
552                "malformed audience {aud:?}: must match [A-Za-z0-9_-]{{1,255}}"
553            ));
554        }
555        // The static map and the store snapshot are checked as two separate sources -- a
556        // selector present in either grants access -- rather than merged into one map, so
557        // neither side is deep-cloned on every request.
558        let store_grants = match &self.store {
559            Some(store) => Some(store.current().await?),
560            None => None,
561        };
562        let granted = self
563            .grants
564            .mint_selectors(aud)
565            .iter()
566            .any(|s| selector_matches(s, caller))
567            || store_grants.as_ref().is_some_and(|grants| {
568                grants
569                    .mint_selectors(aud)
570                    .iter()
571                    .any(|s| selector_matches(s, caller))
572            });
573        if granted {
574            Ok(aud.to_string())
575        } else {
576            Err(anyhow!(
577                "audience {aud:?} is not in the caller's mintable set"
578            ))
579        }
580    }
581}