Skip to main content

aion_server/namespace/
minter.rs

1//! Transport-agnostic minted-on-use namespace hook (Control-Plane Phase 1).
2//!
3//! The single, shared implementation of the auto-create policy: given an
4//! already-authorized namespace set, durably mint each unseen namespace
5//! ([`AutoCreate::Open`]) or gate it ([`AutoCreate::Closed`]). Both mint
6//! choke-points reuse this one type so the policy can never diverge:
7//!
8//! - the worker-registration seam ([`crate::worker::registry::ConnectedWorkerRegistry`],
9//!   the primary minter — S5), and
10//! - the workflow-start seam ([`crate::api::handlers::start_with_placement`], the
11//!   safety net for a client that starts before any worker registers — S6).
12//!
13//! In every case the mint runs strictly AFTER the caller's authorization, so it
14//! can only ever record a namespace the caller is already permitted to use — the
15//! mint is auth-scoped by construction (CVE-2025-14986: open minting and
16//! namespace isolation only coexist when minting is auth-gated).
17
18use std::sync::Arc;
19
20use aion_core::{ClusterEvent, NamespacePlacementWire};
21use aion_store::{MintOutcome, NamespaceOrigin, NamespacePlacement, NamespaceStore};
22
23use crate::cluster_publisher::ClusterEventPublisher;
24use crate::config::AutoCreate;
25use crate::error::ServerError;
26
27/// Minted-on-use hook pairing the durable namespace registry with its
28/// [`AutoCreate`] policy.
29///
30/// Holds an `Arc<dyn NamespaceStore>` and the policy, so it is cheap to clone
31/// and share between the worker-registration and workflow-start mint seams. The
32/// hook is the *only* place the auto-create policy is implemented; both seams
33/// call [`NamespaceMinter::mint_or_gate`], so the behaviour can never diverge
34/// across transports or call sites.
35#[derive(Clone)]
36pub struct NamespaceMinter {
37    store: Arc<dyn NamespaceStore>,
38    policy: AutoCreate,
39    /// Optional ops-console push channel (WS3). When present, a genuinely-new
40    /// namespace (a [`MintOutcome::Created`] edge) emits a durable
41    /// [`ClusterEvent::NamespaceCreated`] delta on the SAME deploy-scoped
42    /// channel that already carries the worker/peer/shard topology deltas — so
43    /// the live namespace panel appends each namespace exactly once with no
44    /// refresh. `None` keeps every existing construction (and every test) silent,
45    /// exactly like the registry's other optional seams.
46    cluster_publisher: Option<ClusterEventPublisher>,
47}
48
49impl std::fmt::Debug for NamespaceMinter {
50    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        formatter
52            .debug_struct("NamespaceMinter")
53            .field("policy", &self.policy)
54            .field("cluster_publisher", &self.cluster_publisher.is_some())
55            .finish_non_exhaustive()
56    }
57}
58
59impl NamespaceMinter {
60    /// Build a minter over a durable namespace store and an auto-create policy.
61    #[must_use]
62    pub fn new(store: Arc<dyn NamespaceStore>, policy: AutoCreate) -> Self {
63        Self {
64            store,
65            policy,
66            cluster_publisher: None,
67        }
68    }
69
70    /// Attach the WS3 cluster-event publisher so a first mint pushes a live
71    /// `namespace created` delta to the ops console (Control-Plane Phase 1, S8).
72    ///
73    /// Pure builder addition: without it the minter behaves exactly as before
74    /// (durable record + the `tracing` audit event only). The publisher is the
75    /// deployment-global cluster channel — the same one the worker registry and
76    /// supervisor emit on — so the delta reuses the existing browser push path
77    /// rather than inventing a parallel channel.
78    #[must_use]
79    pub fn with_cluster_publisher(mut self, publisher: ClusterEventPublisher) -> Self {
80        self.cluster_publisher = Some(publisher);
81        self
82    }
83
84    /// The auto-create policy this minter applies.
85    #[must_use]
86    pub fn policy(&self) -> AutoCreate {
87        self.policy
88    }
89
90    /// Apply the minted-on-use policy to an already-authorized namespace set.
91    ///
92    /// The caller MUST have authorized every namespace in `namespaces` before
93    /// calling this — the mint is auth-scoped by construction, never a path to
94    /// create a namespace the caller cannot use.
95    ///
96    /// - [`AutoCreate::Open`]: each namespace is durably upserted via
97    ///   [`NamespaceStore::register_namespace`] with the given `origin`; a
98    ///   [`MintOutcome::Created`] (first mint) emits a loud structured `tracing`
99    ///   event — the Phase-1 "namespace created" signal (the socket-delta
100    ///   surfacing lands in a later slice). A [`MintOutcome::AlreadyExisted`] is
101    ///   silent (idempotent re-reference), so no duplicate row and no second
102    ///   "created" event ever appear.
103    /// - [`AutoCreate::Closed`]: a namespace with no registry row is rejected
104    ///   with a namespace-denied error; nothing is created.
105    ///
106    /// A [`aion_store::StoreError::NotOwner`] from a quorum mint (this node is
107    /// not the namespace shard's owner) propagates unchanged through `?` as
108    /// [`ServerError::StoreBackend`], which surfaces as the typed, *retryable*
109    /// `NotOwner` wire code — never a silent success.
110    ///
111    /// **Closed-policy existence check (Phase 1).** Existence is probed by
112    /// registry-row presence ([`NamespaceStore::get_namespace`]). In a fresh
113    /// Phase-1 deployment every used namespace already has a row minted on first
114    /// register/start, so a missing row correctly means "never referenced".
115    ///
116    /// # Errors
117    ///
118    /// Returns [`ServerError::StoreBackend`] if a durable upsert/lookup fails
119    /// (including a retryable `NotOwner` fence), or [`ServerError::Namespace`]
120    /// when `closed` rejects an unknown namespace.
121    pub async fn mint_or_gate(
122        &self,
123        namespaces: &[String],
124        origin: NamespaceOrigin,
125    ) -> Result<(), ServerError> {
126        for namespace in namespaces {
127            match self.policy {
128                AutoCreate::Open => {
129                    if self.store.register_namespace(namespace, origin).await?
130                        == MintOutcome::Created
131                    {
132                        self.announce_created(namespace, origin).await?;
133                    }
134                }
135                AutoCreate::Closed => {
136                    if self.store.get_namespace(namespace).await?.is_none() {
137                        return Err(ServerError::namespace_denied(format!(
138                            "namespace {namespace} does not exist and auto_create is closed"
139                        )));
140                    }
141                }
142            }
143        }
144        Ok(())
145    }
146
147    /// Explicit operator create (`POST /namespaces`, S7) routed through the SAME
148    /// `MintOutcome::Created` choke-point so the live "namespace created" delta
149    /// fires once for an operator-minted namespace exactly as it does for a
150    /// worker- or start-minted one.
151    ///
152    /// Unlike [`NamespaceMinter::mint_or_gate`] this never gates on the
153    /// [`AutoCreate::Closed`] policy: an explicit operator create is the
154    /// documented escape hatch that brings a namespace into being in a
155    /// locked-down deployment. The caller MUST have authorized `name` first (the
156    /// HTTP handler runs the grant check), so the create is auth-scoped by
157    /// construction.
158    ///
159    /// Returns the [`MintOutcome`] so the handler can report created-vs-existing
160    /// to the operator. Idempotent: a re-create observes `AlreadyExisted` and
161    /// emits no second delta.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ServerError::StoreBackend`] if the durable upsert/lookup fails
166    /// (including a retryable `NotOwner` fence).
167    pub async fn create_explicit(&self, name: &str) -> Result<MintOutcome, ServerError> {
168        let outcome = self
169            .store
170            .register_namespace(name, NamespaceOrigin::Explicit)
171            .await?;
172        if outcome == MintOutcome::Created {
173            self.announce_created(name, NamespaceOrigin::Explicit)
174                .await?;
175        }
176        Ok(outcome)
177    }
178
179    /// Set an existing namespace's durable placement directive and emit the
180    /// placement-changed socket delta (Control-Plane Phase 2, P2-P2).
181    ///
182    /// The caller MUST have authorized `name` first (the HTTP handler runs the
183    /// SAME grant check `POST /namespaces` does), so the placement change is
184    /// auth-scoped by construction — a caller can never place a namespace it
185    /// cannot access. The durable write is the idempotent quorum value-CAS update
186    /// of the record's `placement` field
187    /// ([`NamespaceStore::set_namespace_placement`]): re-applying the same
188    /// placement is a successful no-op.
189    ///
190    /// Returns `true` when the placement was durably set, or `false` when no
191    /// registry row exists for `name` (placement targets an already-minted
192    /// namespace, so the handler surfaces a not-found rather than minting here).
193    /// The placement-changed delta fires only on a real set (never on the
194    /// not-found path), mirroring the `Created`-edge discipline of
195    /// [`Self::announce_created`].
196    ///
197    /// # Errors
198    ///
199    /// Returns [`ServerError::StoreBackend`] if the durable update fails
200    /// (including a retryable `NotOwner` fence).
201    pub async fn set_placement(
202        &self,
203        name: &str,
204        placement: NamespacePlacement,
205    ) -> Result<bool, ServerError> {
206        if self
207            .store
208            .set_namespace_placement(name, placement.clone())
209            .await?
210            .is_none()
211        {
212            return Ok(false);
213        }
214        self.announce_placement_changed(name, &placement);
215        Ok(true)
216    }
217
218    /// Read a namespace's durable placement directive, for the worker-admission
219    /// gate (Control-Plane Phase 2, P2-I1). Reads the SAME registry record
220    /// [`Self::set_placement`] writes — the single source of truth — so admission
221    /// and dispatch can never disagree on a namespace's placement.
222    ///
223    /// An absent registry row means no placement applies:
224    /// [`NamespacePlacement::Unplaced`] (any worker), so a namespace that has not
225    /// yet been minted never gates a registration.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`ServerError::StoreBackend`] if the durable lookup fails (including
230    /// a retryable `NotOwner` fence).
231    pub async fn placement_of(&self, name: &str) -> Result<NamespacePlacement, ServerError> {
232        Ok(self
233            .store
234            .get_namespace(name)
235            .await?
236            .map(|record| record.placement)
237            .unwrap_or_default())
238    }
239
240    /// Emit the audit event AND (when a publisher is attached) the durable
241    /// `namespace placement changed` socket delta after a placement update.
242    ///
243    /// Fires only on a real durable set (the `set_placement` not-found path
244    /// returns before reaching here). Without a publisher attached it is the audit
245    /// `tracing` event only, exactly like [`Self::announce_created`].
246    fn announce_placement_changed(&self, name: &str, placement: &NamespacePlacement) {
247        let wire = placement_to_wire(placement);
248        tracing::info!(
249            namespace = %name,
250            placement_kind = %wire.kind,
251            "namespace placement changed"
252        );
253        let Some(publisher) = &self.cluster_publisher else {
254            return;
255        };
256        let name = name.to_owned();
257        drop(
258            publisher.emit(move |meta| ClusterEvent::NamespacePlacementChanged {
259                meta,
260                name,
261                placement: wire,
262            }),
263        );
264    }
265
266    /// Emit the loud audit event AND (when a publisher is attached) the durable
267    /// `namespace created` socket delta for a genuinely-new namespace.
268    ///
269    /// Called ONLY on the `MintOutcome::Created` edge, so it fires exactly once
270    /// per genuinely-new namespace and never on an idempotent re-reference. The
271    /// delta's `created_at` is read back from the durable record so the console's
272    /// created column is the registry's authoritative instant, not a re-stamp at
273    /// emit time; if the record cannot be re-read (a racer deprecated it, or a
274    /// quorum hiccup) the audit event still fires and the delta is skipped rather
275    /// than carrying a fabricated timestamp.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`ServerError::StoreBackend`] only if the read-back lookup fails
280    /// at the backend; an absent record (already reconciled away) is not an
281    /// error — the audit event has already fired.
282    async fn announce_created(
283        &self,
284        name: &str,
285        origin: NamespaceOrigin,
286    ) -> Result<(), ServerError> {
287        tracing::info!(
288            namespace = %name,
289            origin = origin_label(origin),
290            "namespace created"
291        );
292        let Some(publisher) = &self.cluster_publisher else {
293            return Ok(());
294        };
295        let Some(record) = self.store.get_namespace(name).await? else {
296            return Ok(());
297        };
298        let name = record.name;
299        let created_at = record.created_at;
300        let label = origin_label(record.origin).to_owned();
301        drop(publisher.emit(move |meta| ClusterEvent::NamespaceCreated {
302            meta,
303            name,
304            created_at,
305            origin: label,
306        }));
307        Ok(())
308    }
309}
310
311/// Project a durable [`NamespacePlacement`] onto its stable wire form for the
312/// cluster socket delta: a `snake_case` `kind` tag plus the (possibly empty)
313/// node-label set. `Unplaced` carries an empty `nodes`; `Prefer`/`Pinned` carry
314/// their deterministically-ordered label set. Kept here (not in the leaf
315/// `aion-core` crate) because only the server depends on `aion-store`'s enum.
316fn placement_to_wire(placement: &NamespacePlacement) -> NamespacePlacementWire {
317    match placement {
318        NamespacePlacement::Unplaced => NamespacePlacementWire {
319            kind: "unplaced".to_owned(),
320            nodes: Vec::new(),
321        },
322        NamespacePlacement::Prefer { nodes } => NamespacePlacementWire {
323            kind: "prefer".to_owned(),
324            nodes: nodes.iter().cloned().collect(),
325        },
326        NamespacePlacement::Pinned { nodes } => NamespacePlacementWire {
327            kind: "pinned".to_owned(),
328            nodes: nodes.iter().cloned().collect(),
329        },
330    }
331}
332
333/// Stable `snake_case` label for the "namespace created" audit event, so the log
334/// field stays the operational identifier (`worker_mint` / `start_mint` /
335/// `explicit` / `inferred_from_state`) regardless of the enum's `Debug` form.
336const fn origin_label(origin: NamespaceOrigin) -> &'static str {
337    match origin {
338        NamespaceOrigin::WorkerMint => "worker_mint",
339        NamespaceOrigin::StartMint => "start_mint",
340        NamespaceOrigin::Explicit => "explicit",
341        NamespaceOrigin::InferredFromState => "inferred_from_state",
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    #![allow(clippy::expect_used)]
348
349    use std::num::NonZeroUsize;
350    use std::sync::Arc;
351
352    use aion_core::ClusterEvent;
353    use aion_store::{InMemoryStore, NamespaceOrigin, NamespaceStore};
354    use futures::StreamExt;
355
356    use super::NamespaceMinter;
357    use crate::cluster_publisher::ClusterEventPublisher;
358    use crate::config::AutoCreate;
359
360    fn publisher() -> ClusterEventPublisher {
361        ClusterEventPublisher::new(NonZeroUsize::new(16).expect("non-zero capacity"))
362    }
363
364    fn open_minter(store: Arc<InMemoryStore>, publisher: ClusterEventPublisher) -> NamespaceMinter {
365        let store: Arc<dyn NamespaceStore> = store;
366        NamespaceMinter::new(store, AutoCreate::Open).with_cluster_publisher(publisher)
367    }
368
369    /// Pull the next delta off the stream, asserting it is a `NamespaceCreated`
370    /// with the `explicit` origin label and returning its name.
371    async fn next_created_name<S>(deltas: &mut S) -> Result<String, Box<dyn std::error::Error>>
372    where
373        S: futures::Stream<
374                Item = Result<ClusterEvent, crate::cluster_publisher::ClusterStreamLagged>,
375            > + Unpin,
376    {
377        let event = deltas
378            .next()
379            .await
380            .ok_or("expected a namespace-created delta")?
381            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
382        match event {
383            ClusterEvent::NamespaceCreated { name, origin, .. } => {
384                assert_eq!(origin, "explicit");
385                Ok(name)
386            }
387            other => Err(format!("expected NamespaceCreated, got {other:?}").into()),
388        }
389    }
390
391    /// The single `MintOutcome::Created` choke-point pushes exactly one durable
392    /// `NamespaceCreated` delta carrying the record's name + origin label, and an
393    /// idempotent re-reference of the SAME namespace (an `AlreadyExisted` touch)
394    /// pushes NOTHING — so the ops console appends each namespace exactly once
395    /// with no refresh and no duplicate row.
396    #[tokio::test]
397    async fn namespace_created_delta_emits_once_on_created_and_not_on_already_existed()
398    -> Result<(), Box<dyn std::error::Error>> {
399        let store = Arc::new(InMemoryStore::default());
400        let publisher = publisher();
401        let mut deltas = publisher.subscribe(0);
402        let minter = open_minter(Arc::clone(&store), publisher);
403
404        // First mint of a brand-new namespace: the Created edge.
405        minter
406            .mint_or_gate(&["orders".to_owned()], NamespaceOrigin::WorkerMint)
407            .await?;
408
409        let first = deltas
410            .next()
411            .await
412            .ok_or("expected one namespace-created delta")?
413            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
414        match first {
415            ClusterEvent::NamespaceCreated {
416                name,
417                origin,
418                created_at,
419                ..
420            } => {
421                assert_eq!(name, "orders");
422                assert_eq!(origin, "worker_mint");
423                // The carried instant is the durable record's own created_at.
424                let record = store
425                    .get_namespace("orders")
426                    .await?
427                    .ok_or("record must exist after a Created mint")?;
428                assert_eq!(created_at, record.created_at);
429            }
430            other => return Err(format!("expected NamespaceCreated, got {other:?}").into()),
431        }
432
433        // Idempotent re-reference of the SAME namespace: an AlreadyExisted touch.
434        // It must NOT emit a second delta. A different new namespace must, so we
435        // can prove the channel is still live (the re-reference produced silence,
436        // not a closed channel).
437        minter
438            .mint_or_gate(&["orders".to_owned()], NamespaceOrigin::WorkerMint)
439            .await?;
440        minter
441            .mint_or_gate(&["billing".to_owned()], NamespaceOrigin::StartMint)
442            .await?;
443
444        let next = deltas
445            .next()
446            .await
447            .ok_or("expected the second namespace's delta")?
448            .map_err(|lag| format!("unexpected lag: {lag:?}"))?;
449        match next {
450            ClusterEvent::NamespaceCreated { name, origin, .. } => {
451                // The very next delta is `billing`, proving the `orders`
452                // re-reference emitted nothing in between (idempotent silence).
453                assert_eq!(name, "billing");
454                assert_eq!(origin, "start_mint");
455            }
456            other => return Err(format!("expected NamespaceCreated, got {other:?}").into()),
457        }
458
459        Ok(())
460    }
461
462    /// The explicit `POST /namespaces` path flows through the SAME choke-point, so
463    /// an operator-minted namespace emits the `NamespaceCreated` delta once on
464    /// create and is silent on an idempotent re-create.
465    #[tokio::test]
466    async fn explicit_create_emits_created_delta_once_then_silent_on_recreate()
467    -> Result<(), Box<dyn std::error::Error>> {
468        let store = Arc::new(InMemoryStore::default());
469        let publisher = publisher();
470        let mut deltas = publisher.subscribe(0);
471        let minter = open_minter(Arc::clone(&store), publisher);
472
473        let created = minter.create_explicit("tenant-a").await?;
474        assert_eq!(created, aion_store::MintOutcome::Created);
475        // Idempotent re-create: AlreadyExisted, and no second delta.
476        let again = minter.create_explicit("tenant-a").await?;
477        assert_eq!(again, aion_store::MintOutcome::AlreadyExisted);
478
479        // Emit one more genuinely-new namespace to bound the read: the next delta
480        // proves the re-create was silent.
481        let _ = minter.create_explicit("tenant-b").await?;
482
483        let first = next_created_name(&mut deltas).await?;
484        let second = next_created_name(&mut deltas).await?;
485        assert_eq!(
486            vec![first, second],
487            vec!["tenant-a".to_owned(), "tenant-b".to_owned()]
488        );
489
490        Ok(())
491    }
492
493    /// Without a publisher attached the minter is silent (durable record + audit
494    /// event only): the registry's other call sites that never wire the channel
495    /// stay byte-identical, and minting never depends on a live subscriber.
496    #[tokio::test]
497    async fn mint_without_publisher_creates_record_but_emits_no_delta()
498    -> Result<(), Box<dyn std::error::Error>> {
499        let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
500        let minter = NamespaceMinter::new(Arc::clone(&store), AutoCreate::Open);
501
502        minter
503            .mint_or_gate(&["orders".to_owned()], NamespaceOrigin::WorkerMint)
504            .await?;
505
506        assert!(
507            store.get_namespace("orders").await?.is_some(),
508            "the durable record is still minted without a publisher"
509        );
510        Ok(())
511    }
512}