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}