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 super::route::{MintCredentials, MintRoute, NamespaceRouting};
24use crate::cluster_publisher::ClusterEventPublisher;
25use crate::config::AutoCreate;
26use crate::error::ServerError;
27
28/// Minted-on-use hook pairing the durable namespace registry with its
29/// [`AutoCreate`] policy.
30///
31/// Holds an `Arc<dyn NamespaceStore>` and the policy, so it is cheap to clone
32/// and share between the worker-registration and workflow-start mint seams. The
33/// hook is the *only* place the auto-create policy is implemented; both seams
34/// call [`NamespaceMinter::mint_or_gate`], so the behaviour can never diverge
35/// across transports or call sites.
36#[derive(Clone)]
37pub struct NamespaceMinter {
38    store: Arc<dyn NamespaceStore>,
39    policy: AutoCreate,
40    /// Optional ops-console push channel (WS3). When present, a genuinely-new
41    /// namespace (a [`MintOutcome::Created`] edge) emits a durable
42    /// [`ClusterEvent::NamespaceCreated`] delta on the SAME deploy-scoped
43    /// channel that already carries the worker/peer/shard topology deltas — so
44    /// the live namespace panel appends each namespace exactly once with no
45    /// refresh. `None` keeps every existing construction (and every test) silent,
46    /// exactly like the registry's other optional seams.
47    cluster_publisher: Option<ClusterEventPublisher>,
48    /// Optional namespace-mint routing context. `None` on every single-node /
49    /// non-clustered boot, where the mint is always local and the minter behaves
50    /// byte-for-byte as it did before routing existed. On a clustered boot it
51    /// carries the three handles that decide, per namespace, whether the durable
52    /// write executes here or on the registry shard's owner.
53    routing: Option<NamespaceRouting>,
54}
55
56impl std::fmt::Debug for NamespaceMinter {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter
59            .debug_struct("NamespaceMinter")
60            .field("policy", &self.policy)
61            .field("cluster_publisher", &self.cluster_publisher.is_some())
62            .field("routing", &self.routing.is_some())
63            .finish_non_exhaustive()
64    }
65}
66
67impl NamespaceMinter {
68    /// Build a minter over a durable namespace store and an auto-create policy.
69    #[must_use]
70    pub fn new(store: Arc<dyn NamespaceStore>, policy: AutoCreate) -> Self {
71        Self {
72            store,
73            policy,
74            cluster_publisher: None,
75            routing: None,
76        }
77    }
78
79    /// Attach the namespace-mint routing context a clustered boot builds, so a
80    /// namespace whose registry shard this node does not own is minted by the
81    /// node that does instead of being fenced forever.
82    ///
83    /// Pure builder addition: without it the minter mints locally exactly as
84    /// before, which is what every single-node boot and every unit test does.
85    #[must_use]
86    pub fn with_routing(mut self, routing: NamespaceRouting) -> Self {
87        self.routing = Some(routing);
88        self
89    }
90
91    /// Drop the routing context, pinning every mint to THIS node.
92    ///
93    /// Used by the owner-side `MintNamespace` handler: a mint that already
94    /// travelled must not travel again, so an arrival whose shard has moved
95    /// on is answered with the local fence's typed refusal rather than a
96    /// re-forward chain. The refusal returns to the initiator, which surfaces
97    /// it; nothing retries internally.
98    #[must_use]
99    pub fn without_routing(mut self) -> Self {
100        self.routing = None;
101        self
102    }
103
104    /// Carry the inbound request's caller credentials onto any forwarded mint,
105    /// so the owning node authorizes the caller exactly as this node did.
106    ///
107    /// A no-op with no routing context attached (nothing can be forwarded).
108    #[must_use]
109    pub fn with_caller_credentials(mut self, credentials: MintCredentials) -> Self {
110        self.routing = self
111            .routing
112            .map(|routing| routing.with_credentials(credentials));
113        self
114    }
115
116    /// Attach the WS3 cluster-event publisher so a first mint pushes a live
117    /// `namespace created` delta to the ops console (Control-Plane Phase 1, S8).
118    ///
119    /// Pure builder addition: without it the minter behaves exactly as before
120    /// (durable record + the `tracing` audit event only). The publisher is the
121    /// deployment-global cluster channel — the same one the worker registry and
122    /// supervisor emit on — so the delta reuses the existing browser push path
123    /// rather than inventing a parallel channel.
124    #[must_use]
125    pub fn with_cluster_publisher(mut self, publisher: ClusterEventPublisher) -> Self {
126        self.cluster_publisher = Some(publisher);
127        self
128    }
129
130    /// The auto-create policy this minter applies.
131    #[must_use]
132    pub fn policy(&self) -> AutoCreate {
133        self.policy
134    }
135
136    /// Apply the minted-on-use policy to an already-authorized namespace set.
137    ///
138    /// The caller MUST have authorized every namespace in `namespaces` before
139    /// calling this — the mint is auth-scoped by construction, never a path to
140    /// create a namespace the caller cannot use.
141    ///
142    /// - [`AutoCreate::Open`]: each namespace is durably upserted via
143    ///   [`NamespaceStore::register_namespace`] with the given `origin`; a
144    ///   [`MintOutcome::Created`] (first mint) emits a loud structured `tracing`
145    ///   event — the Phase-1 "namespace created" signal (the socket-delta
146    ///   surfacing lands in a later slice). A [`MintOutcome::AlreadyExisted`] is
147    ///   silent (idempotent re-reference), so no duplicate row and no second
148    ///   "created" event ever appear.
149    /// - [`AutoCreate::Closed`]: a namespace with no registry row is rejected
150    ///   with a namespace-denied error; nothing is created.
151    ///
152    /// A [`aion_store::StoreError::NotOwner`] from a quorum mint (this node is
153    /// not the namespace shard's owner) propagates unchanged through `?` as
154    /// [`ServerError::StoreBackend`], which surfaces as the typed, *retryable*
155    /// `NotOwner` wire code — never a silent success.
156    ///
157    /// **Closed-policy existence check (Phase 1).** Existence is probed by
158    /// registry-row presence ([`NamespaceStore::get_namespace`]). In a fresh
159    /// Phase-1 deployment every used namespace already has a row minted on first
160    /// register/start, so a missing row correctly means "never referenced".
161    ///
162    /// # Errors
163    ///
164    /// Returns [`ServerError::StoreBackend`] if a durable upsert/lookup fails
165    /// (including a retryable `NotOwner` fence), or [`ServerError::Namespace`]
166    /// when `closed` rejects an unknown namespace.
167    pub async fn mint_or_gate(
168        &self,
169        namespaces: &[String],
170        origin: NamespaceOrigin,
171    ) -> Result<(), ServerError> {
172        for namespace in namespaces {
173            match self.policy {
174                AutoCreate::Open => self.mint(namespace, origin).await?,
175                AutoCreate::Closed => {
176                    if self.store.get_namespace(namespace).await?.is_none() {
177                        return Err(ServerError::namespace_denied(format!(
178                            "namespace {namespace} does not exist and auto_create is closed"
179                        )));
180                    }
181                }
182            }
183        }
184        Ok(())
185    }
186
187    /// Durably mint ONE namespace under the `open` policy, on the node that owns
188    /// its registry record.
189    ///
190    /// With no routing context (every single-node / non-clustered boot) this is
191    /// byte-for-byte the local upsert it always was. With one, the namespace's
192    /// registry shard decides: this node writes when it owns the shard, or when
193    /// ownership is not known with confidence (the receiver fence — not the
194    /// directory — is the authority, so an uncertain answer is resolved by
195    /// attempting and being told the truth). Only a confidently-remote,
196    /// dialable owner is forwarded to, and then the WHOLE read-modify-write
197    /// happens there.
198    ///
199    /// The `Created` edge is therefore decided wherever the write executes: a
200    /// forwarded mint's `NamespaceCreated` delta fires on the OWNER's publisher,
201    /// which is the deployment-global channel the ops console already listens
202    /// on. That is the one observable difference a forwarded mint makes.
203    async fn mint(&self, namespace: &str, origin: NamespaceOrigin) -> Result<(), ServerError> {
204        if let Some(routing) = &self.routing
205            && let MintRoute::Remote { shard, target } = routing.route_for(namespace)
206        {
207            return routing.forward(namespace, target, shard, origin).await;
208        }
209        if self.store.register_namespace(namespace, origin).await? == MintOutcome::Created {
210            self.announce_created(namespace, origin).await?;
211        }
212        Ok(())
213    }
214
215    /// Explicit operator create (`POST /namespaces`, S7) routed through the SAME
216    /// `MintOutcome::Created` choke-point so the live "namespace created" delta
217    /// fires once for an operator-minted namespace exactly as it does for a
218    /// worker- or start-minted one.
219    ///
220    /// Unlike [`NamespaceMinter::mint_or_gate`] this never gates on the
221    /// [`AutoCreate::Closed`] policy: an explicit operator create is the
222    /// documented escape hatch that brings a namespace into being in a
223    /// locked-down deployment. The caller MUST have authorized `name` first (the
224    /// HTTP handler runs the grant check), so the create is auth-scoped by
225    /// construction.
226    ///
227    /// Returns the [`MintOutcome`] so the handler can report created-vs-existing
228    /// to the operator. Idempotent: a re-create observes `AlreadyExisted` and
229    /// emits no second delta.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`ServerError::StoreBackend`] if the durable upsert/lookup fails
234    /// (including a retryable `NotOwner` fence).
235    pub async fn create_explicit(&self, name: &str) -> Result<MintOutcome, ServerError> {
236        let outcome = self
237            .store
238            .register_namespace(name, NamespaceOrigin::Explicit)
239            .await?;
240        if outcome == MintOutcome::Created {
241            self.announce_created(name, NamespaceOrigin::Explicit)
242                .await?;
243        }
244        Ok(outcome)
245    }
246
247    /// Set an existing namespace's durable placement directive and emit the
248    /// placement-changed socket delta (Control-Plane Phase 2, P2-P2).
249    ///
250    /// The caller MUST have authorized `name` first (the HTTP handler runs the
251    /// SAME grant check `POST /namespaces` does), so the placement change is
252    /// auth-scoped by construction — a caller can never place a namespace it
253    /// cannot access. The durable write is the idempotent quorum value-CAS update
254    /// of the record's `placement` field
255    /// ([`NamespaceStore::set_namespace_placement`]): re-applying the same
256    /// placement is a successful no-op.
257    ///
258    /// Returns `true` when the placement was durably set, or `false` when no
259    /// registry row exists for `name` (placement targets an already-minted
260    /// namespace, so the handler surfaces a not-found rather than minting here).
261    /// The placement-changed delta fires only on a real set (never on the
262    /// not-found path), mirroring the `Created`-edge discipline of
263    /// [`Self::announce_created`].
264    ///
265    /// # Errors
266    ///
267    /// Returns [`ServerError::StoreBackend`] if the durable update fails
268    /// (including a retryable `NotOwner` fence).
269    pub async fn set_placement(
270        &self,
271        name: &str,
272        placement: NamespacePlacement,
273    ) -> Result<bool, ServerError> {
274        if self
275            .store
276            .set_namespace_placement(name, placement.clone())
277            .await?
278            .is_none()
279        {
280            return Ok(false);
281        }
282        self.announce_placement_changed(name, &placement);
283        Ok(true)
284    }
285
286    /// Read a namespace's durable placement directive, for the worker-admission
287    /// gate (Control-Plane Phase 2, P2-I1). Reads the SAME registry record
288    /// [`Self::set_placement`] writes — the single source of truth — so admission
289    /// and dispatch can never disagree on a namespace's placement.
290    ///
291    /// An absent registry row means no placement applies:
292    /// [`NamespacePlacement::Unplaced`] (any worker), so a namespace that has not
293    /// yet been minted never gates a registration.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`ServerError::StoreBackend`] if the durable lookup fails (including
298    /// a retryable `NotOwner` fence).
299    pub async fn placement_of(&self, name: &str) -> Result<NamespacePlacement, ServerError> {
300        Ok(self
301            .store
302            .get_namespace(name)
303            .await?
304            .map(|record| record.placement)
305            .unwrap_or_default())
306    }
307
308    /// Emit the audit event AND (when a publisher is attached) the durable
309    /// `namespace placement changed` socket delta after a placement update.
310    ///
311    /// Fires only on a real durable set (the `set_placement` not-found path
312    /// returns before reaching here). Without a publisher attached it is the audit
313    /// `tracing` event only, exactly like [`Self::announce_created`].
314    fn announce_placement_changed(&self, name: &str, placement: &NamespacePlacement) {
315        let wire = placement_to_wire(placement);
316        tracing::info!(
317            namespace = %name,
318            placement_kind = %wire.kind,
319            "namespace placement changed"
320        );
321        let Some(publisher) = &self.cluster_publisher else {
322            return;
323        };
324        let name = name.to_owned();
325        drop(
326            publisher.emit(move |meta| ClusterEvent::NamespacePlacementChanged {
327                meta,
328                name,
329                placement: wire,
330            }),
331        );
332    }
333
334    /// Emit the loud audit event AND (when a publisher is attached) the durable
335    /// `namespace created` socket delta for a genuinely-new namespace.
336    ///
337    /// Called ONLY on the `MintOutcome::Created` edge, so it fires exactly once
338    /// per genuinely-new namespace and never on an idempotent re-reference. The
339    /// delta's `created_at` is read back from the durable record so the console's
340    /// created column is the registry's authoritative instant, not a re-stamp at
341    /// emit time; if the record cannot be re-read (a racer deprecated it, or a
342    /// quorum hiccup) the audit event still fires and the delta is skipped rather
343    /// than carrying a fabricated timestamp.
344    ///
345    /// # Errors
346    ///
347    /// Returns [`ServerError::StoreBackend`] only if the read-back lookup fails
348    /// at the backend; an absent record (already reconciled away) is not an
349    /// error — the audit event has already fired.
350    async fn announce_created(
351        &self,
352        name: &str,
353        origin: NamespaceOrigin,
354    ) -> Result<(), ServerError> {
355        tracing::info!(
356            namespace = %name,
357            origin = origin_label(origin),
358            "namespace created"
359        );
360        let Some(publisher) = &self.cluster_publisher else {
361            return Ok(());
362        };
363        let Some(record) = self.store.get_namespace(name).await? else {
364            return Ok(());
365        };
366        let name = record.name;
367        let created_at = record.created_at;
368        let label = origin_label(record.origin).to_owned();
369        drop(publisher.emit(move |meta| ClusterEvent::NamespaceCreated {
370            meta,
371            name,
372            created_at,
373            origin: label,
374        }));
375        Ok(())
376    }
377}
378
379/// Project a durable [`NamespacePlacement`] onto its stable wire form for the
380/// cluster socket delta: a `snake_case` `kind` tag plus the (possibly empty)
381/// node-label set. `Unplaced` carries an empty `nodes`; `Prefer`/`Pinned` carry
382/// their deterministically-ordered label set. Kept here (not in the leaf
383/// `aion-core` crate) because only the server depends on `aion-store`'s enum.
384fn placement_to_wire(placement: &NamespacePlacement) -> NamespacePlacementWire {
385    match placement {
386        NamespacePlacement::Unplaced => NamespacePlacementWire {
387            kind: "unplaced".to_owned(),
388            nodes: Vec::new(),
389        },
390        NamespacePlacement::Prefer { nodes } => NamespacePlacementWire {
391            kind: "prefer".to_owned(),
392            nodes: nodes.iter().cloned().collect(),
393        },
394        NamespacePlacement::Pinned { nodes } => NamespacePlacementWire {
395            kind: "pinned".to_owned(),
396            nodes: nodes.iter().cloned().collect(),
397        },
398    }
399}
400
401/// Stable `snake_case` label for the "namespace created" audit event, so the log
402/// field stays the operational identifier (`worker_mint` / `start_mint` /
403/// `explicit` / `inferred_from_state`) regardless of the enum's `Debug` form.
404const fn origin_label(origin: NamespaceOrigin) -> &'static str {
405    match origin {
406        NamespaceOrigin::WorkerMint => "worker_mint",
407        NamespaceOrigin::StartMint => "start_mint",
408        NamespaceOrigin::Explicit => "explicit",
409        NamespaceOrigin::InferredFromState => "inferred_from_state",
410    }
411}
412
413/// Unit tests live in a sibling file so this module stays under the 500-line
414/// law with the test bodies intact rather than thinned.
415#[cfg(test)]
416#[path = "minter_tests.rs"]
417mod tests;