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}