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 if let MintRoute::Remote { shard, target } = routing.route_for(namespace) {
206 return routing.forward(namespace, target, shard, origin).await;
207 }
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;