Skip to main content

aion_server/namespace/
resolver.rs

1//! Namespace resolver type wired into shared state.
2//!
3//! Workflow→namespace ownership is a projection of durable history: the server
4//! records the owning namespace as the `aion.namespace` search attribute when a
5//! workflow starts, and verification folds that attribute back out of the
6//! workflow's recorded events. Nothing about ownership lives only in memory, so
7//! a server restart can never orphan a workflow from its namespace.
8
9use std::collections::{BTreeSet, HashMap};
10use std::sync::{Arc, RwLock};
11
12use aion::Engine;
13use aion_core::{ScheduleId, SearchAttributeValue, WorkflowId, search_attributes_from_events};
14use aion_proto::WireError;
15use async_trait::async_trait;
16
17use crate::config::{NamespaceConfig, NamespaceMode};
18use crate::error::ServerError;
19
20use super::schedule_source::{HistoryScheduleNamespaceSource, ScheduleNamespaceSource};
21
22/// Search attribute name that records the owning namespace of every workflow
23/// started through this server.
24pub const NAMESPACE_ATTRIBUTE: &str = "aion.namespace";
25
26/// Search attribute name that records the default task queue a workflow was
27/// started against (the namespace × `task_queue` targeting story). Recorded
28/// durably in the same atomic append as `WorkflowStarted`, mirroring
29/// [`NAMESPACE_ATTRIBUTE`], so the start-time queue selection survives replay
30/// and failover. Absent when the start did not select a task queue (the
31/// workflow falls back to the namespace's default queue).
32///
33/// Aliases [`aion_core::START_TIME_TASK_QUEUE_ATTRIBUTE`] rather than
34/// redeclaring the literal, so the server's recorded attribute and the engine's
35/// history-derived start-time queue (#144) cannot drift.
36pub const TASK_QUEUE_ATTRIBUTE: &str = aion_core::START_TIME_TASK_QUEUE_ATTRIBUTE;
37
38/// Where a caller's grants came from, so a denial message can point the
39/// operator at the knob that actually carries the grant (the development
40/// headers, or the validated token's claims).
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub(crate) enum GrantSource {
43    /// Grants parsed from the development `x-aion-namespaces` /
44    /// `x-aion-deploy` headers.
45    NamespacesHeader,
46    /// Grants carried by a validated token's claims.
47    TokenClaim,
48    /// Full access granted server-side because no auth is configured
49    /// (single-tenant operator mode): the caller is the operator and holds
50    /// every namespace plus the deployment-wide deploy grant. This grant is
51    /// decided at request time from `auth.enabled == false`, never asserted by
52    /// the client.
53    Operator,
54}
55
56impl GrantSource {
57    /// Stable label for audit log fields.
58    pub(crate) const fn label(self) -> &'static str {
59        match self {
60            Self::NamespacesHeader => "header",
61            Self::TokenClaim => "token_claim",
62            Self::Operator => "operator",
63        }
64    }
65}
66
67/// Authenticated caller metadata supplied by an adapter boundary.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct CallerIdentity {
70    subject: String,
71    namespaces: BTreeSet<String>,
72    denial_reason: Option<String>,
73    grant_source: GrantSource,
74    /// Whether the caller holds the deployment-wide deploy grant (the
75    /// `deploy` token claim, or the `x-aion-deploy` development header).
76    deploy: bool,
77    /// Whether the caller holds access to every namespace without enumerating
78    /// them. Set only by [`CallerIdentity::operator`] (auth-off single-tenant
79    /// operator mode); always `false` for header- and token-sourced
80    /// identities, which must enumerate their grants.
81    all_namespaces: bool,
82}
83
84impl CallerIdentity {
85    /// Build a caller identity whose namespace grants came from the
86    /// development `x-aion-namespaces` header.
87    #[must_use]
88    pub fn new(subject: impl Into<String>, namespaces: impl IntoIterator<Item = String>) -> Self {
89        Self {
90            subject: subject.into(),
91            namespaces: namespaces.into_iter().collect(),
92            denial_reason: None,
93            grant_source: GrantSource::NamespacesHeader,
94            deploy: false,
95            all_namespaces: false,
96        }
97    }
98
99    /// Build a caller identity whose namespace grants came from a validated
100    /// token's namespace claim (the real JWT path), so denial messages direct
101    /// the operator to the token grant instead of the development header.
102    #[must_use]
103    pub fn from_token_claims(
104        subject: impl Into<String>,
105        namespaces: impl IntoIterator<Item = String>,
106    ) -> Self {
107        Self {
108            subject: subject.into(),
109            namespaces: namespaces.into_iter().collect(),
110            denial_reason: None,
111            grant_source: GrantSource::TokenClaim,
112            deploy: false,
113            all_namespaces: false,
114        }
115    }
116
117    /// Build the single-tenant operator identity: full access to every
118    /// namespace plus the deployment-wide deploy grant, with no namespaces
119    /// enumerated.
120    ///
121    /// This is the server's request-time decision when no auth is configured
122    /// (`auth.enabled == false`): the caller IS the operator. It is constructed
123    /// only at an adapter boundary that has already established auth is off; it
124    /// must never be reachable on the auth-enabled path, where grants come from
125    /// validated token claims (or the development-token path).
126    #[must_use]
127    pub fn operator(subject: impl Into<String>) -> Self {
128        Self {
129            subject: subject.into(),
130            namespaces: BTreeSet::new(),
131            denial_reason: None,
132            grant_source: GrantSource::Operator,
133            deploy: true,
134            all_namespaces: true,
135        }
136    }
137
138    /// Attach the deployment-wide deploy grant decision to this identity.
139    ///
140    /// The grant is engine-global, never namespace-scoped: loading a package
141    /// re-points routing for a workflow type that is startable from every
142    /// namespace, so a namespace-valued grant would promise an isolation the
143    /// engine does not provide.
144    #[must_use]
145    pub fn with_deploy(mut self, deploy: bool) -> Self {
146        self.deploy = deploy;
147        self
148    }
149
150    /// Whether the caller holds the deployment-wide deploy grant.
151    #[must_use]
152    pub const fn deploy_granted(&self) -> bool {
153        self.deploy
154    }
155
156    /// Build a caller identity that must be denied with a transport-specific reason.
157    #[must_use]
158    pub fn denied(subject: impl Into<String>, reason: impl Into<String>) -> Self {
159        Self {
160            subject: subject.into(),
161            namespaces: BTreeSet::new(),
162            denial_reason: Some(reason.into()),
163            grant_source: GrantSource::NamespacesHeader,
164            deploy: false,
165            all_namespaces: false,
166        }
167    }
168
169    /// Caller subject as authenticated by the transport.
170    #[must_use]
171    pub fn subject(&self) -> &str {
172        &self.subject
173    }
174
175    /// Namespaces this caller is authorized for, in sorted order.
176    ///
177    /// Backed by a [`BTreeSet`], so the returned vector is already
178    /// lexicographically ordered with no duplicates.
179    #[must_use]
180    pub fn namespaces(&self) -> Vec<String> {
181        self.namespaces.iter().cloned().collect()
182    }
183
184    /// Whether this caller holds access to every namespace without enumerating
185    /// them. True only for the single-tenant operator identity (auth-off
186    /// operator mode); the all-access grant is signaled here, not by
187    /// [`Self::namespaces`], which stays the explicit (empty) set.
188    #[must_use]
189    pub const fn all_namespaces(&self) -> bool {
190        self.all_namespaces
191    }
192
193    /// Whether this caller may access `namespace`: the all-namespaces operator
194    /// always can; an enumerated caller can only for a granted name.
195    ///
196    /// This is the existence-leak boundary for read enumeration (`GET
197    /// /namespaces`): a caller must never learn that a namespace it cannot
198    /// access exists, so the durable set is filtered through this predicate at
199    /// the read hop, exactly as [`Self::resolve`]'s grant check gates the access
200    /// hop.
201    pub(crate) fn can_access(&self, namespace: &str) -> bool {
202        self.all_namespaces || self.namespaces.contains(namespace)
203    }
204
205    pub(crate) fn denial_reason(&self) -> Option<&str> {
206        self.denial_reason.as_deref()
207    }
208
209    /// Where this caller's grants came from, for grant-source-aware denials
210    /// and audit fields.
211    pub(crate) const fn grant_source(&self) -> GrantSource {
212        self.grant_source
213    }
214}
215
216/// Namespace-scoped access to the embedded engine.
217#[derive(Clone)]
218pub struct ScopedEngine {
219    namespace: String,
220    engine: Option<Arc<Engine>>,
221}
222
223impl ScopedEngine {
224    /// Authorized namespace attached to this engine access.
225    #[must_use]
226    pub fn namespace(&self) -> &str {
227        &self.namespace
228    }
229
230    /// Borrow the authorized engine handle for adapter code after guard approval.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`ServerError::Config`] only for resolver instances constructed
235    /// without an engine for unit tests.
236    pub fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
237        self.engine.as_ref().ok_or_else(|| ServerError::Config {
238            message: "namespace resolver has no engine handle".to_owned(),
239        })
240    }
241}
242
243/// Durable per-workflow attribution facts projected from recorded history.
244///
245/// Namespace ownership and workflow type are both immutable projections of the
246/// same durable history (ownership is recorded atomically with the
247/// `WorkflowStarted` batch; the type is the most recent run's recorded
248/// `WorkflowStarted` type), so one read serves both consumers.
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct WorkflowAttribution {
251    /// Namespace recorded as the workflow's owner.
252    pub namespace: String,
253    /// Workflow type recorded by the most recent `WorkflowStarted` event, or
254    /// [`None`] when the history records no started run.
255    pub workflow_type: Option<String>,
256}
257
258/// Durable source of workflow→namespace ownership and type attribution facts.
259///
260/// The production implementation projects attribution from recorded workflow
261/// history; tests substitute a static fixture to prove adapter-boundary
262/// denials without an engine.
263#[async_trait]
264pub trait WorkflowNamespaceSource: Send + Sync {
265    /// Returns the attribution recorded for a workflow, or [`None`] when the
266    /// workflow is unknown or recorded no namespace attribute.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`ServerError`] when the underlying ownership data cannot be read.
271    async fn workflow_attribution(
272        &self,
273        workflow_id: &WorkflowId,
274    ) -> Result<Option<WorkflowAttribution>, ServerError>;
275}
276
277/// Production attribution source: folds the `aion.namespace` search attribute
278/// and the most recent `WorkflowStarted` type out of the workflow's durable
279/// event history in a single read.
280struct HistoryNamespaceSource {
281    engine: Arc<Engine>,
282}
283
284#[async_trait]
285impl WorkflowNamespaceSource for HistoryNamespaceSource {
286    async fn workflow_attribution(
287        &self,
288        workflow_id: &WorkflowId,
289    ) -> Result<Option<WorkflowAttribution>, ServerError> {
290        let history = self
291            .engine
292            .store()
293            .read_history(workflow_id)
294            .await
295            .map_err(ServerError::from)?;
296        let namespace = match search_attributes_from_events(&history).remove(NAMESPACE_ATTRIBUTE) {
297            Some(SearchAttributeValue::String(namespace)) => namespace,
298            Some(other) => {
299                return Err(ServerError::Config {
300                    message: format!(
301                        "workflow {workflow_id} recorded a non-string {NAMESPACE_ATTRIBUTE} search attribute: {other:?}"
302                    ),
303                });
304            }
305            None => return Ok(None),
306        };
307        // Continue-as-new runs share one history; the most recent
308        // `WorkflowStarted` carries the current run's workflow type.
309        let workflow_type = history.iter().rev().find_map(|event| match event {
310            aion_core::Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
311            _ => None,
312        });
313        Ok(Some(WorkflowAttribution {
314            namespace,
315            workflow_type,
316        }))
317    }
318}
319
320/// Static workflow→namespace fixture for adapter-boundary tests and alternate
321/// wiring that must authorize without an engine handle.
322#[derive(Clone, Default)]
323pub struct StaticWorkflowNamespaces {
324    inner: Arc<RwLock<HashMap<WorkflowId, WorkflowAttribution>>>,
325}
326
327impl StaticWorkflowNamespaces {
328    /// Record that a workflow is owned by a namespace, with no recorded
329    /// workflow type (the fixture equivalent of a history without a
330    /// `WorkflowStarted` event).
331    ///
332    /// # Errors
333    ///
334    /// Returns [`ServerError::LockPoisoned`] if the fixture lock was poisoned.
335    pub fn record(&self, workflow_id: WorkflowId, namespace: &str) -> Result<(), ServerError> {
336        self.insert(
337            workflow_id,
338            WorkflowAttribution {
339                namespace: namespace.to_owned(),
340                workflow_type: None,
341            },
342        )
343    }
344
345    /// Record that a workflow is owned by a namespace and carries a recorded
346    /// workflow type.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`ServerError::LockPoisoned`] if the fixture lock was poisoned.
351    pub fn record_with_type(
352        &self,
353        workflow_id: WorkflowId,
354        namespace: &str,
355        workflow_type: &str,
356    ) -> Result<(), ServerError> {
357        self.insert(
358            workflow_id,
359            WorkflowAttribution {
360                namespace: namespace.to_owned(),
361                workflow_type: Some(workflow_type.to_owned()),
362            },
363        )
364    }
365
366    fn insert(
367        &self,
368        workflow_id: WorkflowId,
369        attribution: WorkflowAttribution,
370    ) -> Result<(), ServerError> {
371        let mut ownership = self
372            .inner
373            .write()
374            .map_err(|_| ServerError::lock_poisoned("namespace workflow ownership"))?;
375        ownership.insert(workflow_id, attribution);
376        Ok(())
377    }
378}
379
380#[async_trait]
381impl WorkflowNamespaceSource for StaticWorkflowNamespaces {
382    async fn workflow_attribution(
383        &self,
384        workflow_id: &WorkflowId,
385    ) -> Result<Option<WorkflowAttribution>, ServerError> {
386        let ownership = self
387            .inner
388            .read()
389            .map_err(|_| ServerError::lock_poisoned("namespace workflow ownership"))?;
390        Ok(ownership.get(workflow_id).cloned())
391    }
392}
393
394/// Resolver that authorizes callers and yields namespace-scoped engine access.
395#[derive(Clone)]
396pub struct NamespaceResolver {
397    mode: NamespaceMode,
398    engine: Option<Arc<Engine>>,
399    ownership: Arc<dyn WorkflowNamespaceSource>,
400    schedule_ownership: Arc<dyn ScheduleNamespaceSource>,
401}
402
403impl NamespaceResolver {
404    /// Build a resolver from operator-supplied namespace configuration and the
405    /// engine selected for this deployment.
406    #[must_use]
407    pub fn from_config(config: NamespaceConfig, engine: Arc<Engine>) -> Self {
408        Self {
409            mode: config.mode,
410            ownership: Arc::new(HistoryNamespaceSource {
411                engine: Arc::clone(&engine),
412            }),
413            schedule_ownership: Arc::new(HistoryScheduleNamespaceSource::new(Arc::clone(&engine))),
414            engine: Some(engine),
415        }
416    }
417
418    /// Build a resolver from explicit parts for tests and alternate wiring.
419    #[must_use]
420    pub fn from_parts(
421        mode: NamespaceMode,
422        engine: Option<Arc<Engine>>,
423        ownership: Arc<dyn WorkflowNamespaceSource>,
424        schedule_ownership: Arc<dyn ScheduleNamespaceSource>,
425    ) -> Self {
426        Self {
427            mode,
428            engine,
429            ownership,
430            schedule_ownership,
431        }
432    }
433
434    /// Build a resolver that performs authorization and ownership checks only.
435    ///
436    /// This constructor is intended for adapter-boundary unit tests that must
437    /// prove denied operations do not reach any engine handle.
438    #[must_use]
439    pub fn authorization_only(
440        mode: NamespaceMode,
441        ownership: impl WorkflowNamespaceSource + 'static,
442        schedule_ownership: impl ScheduleNamespaceSource + 'static,
443    ) -> Self {
444        Self::from_parts(
445            mode,
446            None,
447            Arc::new(ownership),
448            Arc::new(schedule_ownership),
449        )
450    }
451
452    /// Inspect the configured namespace mode.
453    #[must_use]
454    pub const fn mode(&self) -> &NamespaceMode {
455        &self.mode
456    }
457
458    /// Borrow the engine handle for engine-global (non-namespace) operations.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`ServerError::Config`] only for resolver instances constructed
463    /// without an engine for unit tests.
464    pub(crate) fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
465        self.engine.as_ref().ok_or_else(|| ServerError::Config {
466            message: "namespace resolver has no engine handle".to_owned(),
467        })
468    }
469
470    /// Shut down the engine owned by this resolver.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`ServerError::Config`] when no engine is attached, or [`ServerError::EngineCall`]
475    /// when the engine rejects shutdown.
476    pub fn shutdown_engine(&self) -> Result<(), ServerError> {
477        self.engine
478            .as_ref()
479            .ok_or_else(|| ServerError::Config {
480                message: "namespace resolver has no engine handle".to_owned(),
481            })?
482            .shutdown()
483            .map_err(ServerError::from)
484    }
485
486    /// Authorize a caller for a requested namespace and return scoped engine
487    /// access if allowed.
488    ///
489    /// # Errors
490    ///
491    /// Returns [`ServerError::Namespace`] when the caller is not authorized for
492    /// the namespace selected by the wire request.
493    pub(super) fn resolve(
494        &self,
495        caller: &CallerIdentity,
496        requested_namespace: &str,
497    ) -> Result<ScopedEngine, ServerError> {
498        if requested_namespace.is_empty() {
499            return Err(ServerError::namespace_denied(
500                "requested namespace must not be empty",
501            ));
502        }
503
504        if let Some(reason) = caller.denial_reason() {
505            return Err(ServerError::namespace_denied(reason));
506        }
507
508        match &self.mode {
509            NamespaceMode::SingleTenant { namespace } if namespace == requested_namespace => {
510                Ok(self.scoped(requested_namespace))
511            }
512            NamespaceMode::SharedEngine if caller.can_access(requested_namespace) => {
513                Ok(self.scoped(requested_namespace))
514            }
515            NamespaceMode::SingleTenant { .. } | NamespaceMode::SharedEngine => {
516                Err(namespace_denied(caller, requested_namespace))
517            }
518        }
519    }
520
521    /// Verify durable workflow ownership against the requested namespace.
522    ///
523    /// `NamespaceDenied` means exactly one thing: the caller has no grant for
524    /// the requested namespace, and that is decided by [`Self::resolve`] before
525    /// this check runs. Workflow-level visibility misses are `NotFound` to
526    /// prevent existence leaks: when the caller's requested namespace is
527    /// granted but the workflow's recorded owner namespace is absent (unknown
528    /// workflow, or no recorded attribute) or different (owned by another
529    /// tenant), both cases return the identical `not_found` wire error with
530    /// the identical message, so a cross-tenant probe is byte-for-byte
531    /// indistinguishable from querying a workflow that never existed.
532    ///
533    /// # Errors
534    ///
535    /// Returns a [`ServerError::Wire`] `not_found` error when the workflow is
536    /// not visible in the requested namespace; ownership-source read failures
537    /// surface as their own typed errors.
538    pub async fn verify_workflow_ownership(
539        &self,
540        namespace: &str,
541        workflow_id: &WorkflowId,
542    ) -> Result<(), ServerError> {
543        match self.workflow_attribution(namespace, workflow_id).await? {
544            Some(_) => Ok(()),
545            None => Err(ServerError::Wire {
546                wire: WireError::not_found(format!("workflow not found in namespace {namespace}")),
547            }),
548        }
549    }
550
551    /// Read a workflow's durable attribution scoped to one namespace.
552    ///
553    /// Returns the recorded attribution only when the workflow's recorded
554    /// owner namespace equals `namespace`. Foreign-owned and unknown workflows
555    /// both yield [`None`] (anti-existence-leak: callers must treat the two
556    /// cases identically and never disclose which one occurred).
557    ///
558    /// This is the single read that serves both the namespace verdict and the
559    /// workflow-type lookup at the streaming seam — one durable history read
560    /// per workflow answers both questions.
561    ///
562    /// # Errors
563    ///
564    /// Returns [`ServerError`] when the underlying ownership data cannot be
565    /// read; callers must fail loudly rather than guessing.
566    pub async fn workflow_attribution(
567        &self,
568        namespace: &str,
569        workflow_id: &WorkflowId,
570    ) -> Result<Option<WorkflowAttribution>, ServerError> {
571        Ok(self
572            .ownership
573            .workflow_attribution(workflow_id)
574            .await?
575            .filter(|attribution| attribution.namespace == namespace))
576    }
577
578    /// Read a workflow's recorded attribution WITHOUT scoping it to a namespace.
579    ///
580    /// [`Self::workflow_attribution`] is the right read whenever the caller
581    /// already named the namespace it is asking about — it answers the scoped
582    /// question and hides everything else behind the anti-existence-leak
583    /// `None`. A fleet-wide SWEEP cannot use it: the sweep starts from a set of
584    /// workflow ids and does not yet know which namespace each belongs to, so
585    /// scoping first would require guessing the answer it is trying to read.
586    ///
587    /// The grant filter is therefore the CALLER's obligation here, and it is not
588    /// optional. Every use must drop entries the caller cannot access — see
589    /// [`CallerIdentity::can_access`] — before anything reaches a response body,
590    /// exactly as the enumeration reads do. Returning the raw attribution keeps
591    /// that filter visible at the sweep, rather than a scoped read silently
592    /// reporting `None` for a workflow the caller could in fact see.
593    ///
594    /// `None` means the workflow recorded no owning namespace at all: an
595    /// unattributed run, not a denied one. The two are different facts and the
596    /// caller must not merge them.
597    ///
598    /// # Errors
599    ///
600    /// Returns [`ServerError`] when the underlying ownership data cannot be
601    /// read; callers must fail loudly rather than guessing.
602    pub async fn recorded_workflow_attribution(
603        &self,
604        workflow_id: &WorkflowId,
605    ) -> Result<Option<WorkflowAttribution>, ServerError> {
606        self.ownership.workflow_attribution(workflow_id).await
607    }
608
609    /// Verify durable schedule ownership against the requested namespace.
610    ///
611    /// `NamespaceDenied` means exactly one thing: the caller has no grant for
612    /// the requested namespace, and that is decided by [`Self::resolve`] before
613    /// this check runs. Schedule-level visibility misses are `NotFound` to
614    /// prevent existence leaks: when the caller's requested namespace is
615    /// granted but the schedule's creation-recorded owner namespace is absent
616    /// (unknown schedule, or no recorded attribute) or different (owned by
617    /// another tenant), both cases return the identical `not_found` wire error
618    /// with the identical message, so a cross-tenant probe is byte-for-byte
619    /// indistinguishable from targeting a schedule that never existed.
620    ///
621    /// # Errors
622    ///
623    /// Returns a [`ServerError::Wire`] `not_found` error when the schedule is
624    /// not visible in the requested namespace; ownership-source read failures
625    /// surface as their own typed errors.
626    pub async fn verify_schedule_ownership(
627        &self,
628        namespace: &str,
629        schedule_id: &ScheduleId,
630    ) -> Result<(), ServerError> {
631        match self
632            .schedule_ownership
633            .schedule_namespace(schedule_id)
634            .await?
635        {
636            Some(owner) if owner == namespace => Ok(()),
637            // Anti-existence-leak: absent and foreign ownership must be one
638            // identical NotFound, never a distinguishable denial.
639            Some(_) | None => Err(ServerError::Wire {
640                wire: WireError::not_found(format!("schedule not found in namespace {namespace}")),
641            }),
642        }
643    }
644
645    fn scoped(&self, namespace: &str) -> ScopedEngine {
646        ScopedEngine {
647            namespace: namespace.to_owned(),
648            engine: self.engine.clone(),
649        }
650    }
651}
652
653fn namespace_denied(caller: &CallerIdentity, requested_namespace: &str) -> ServerError {
654    let hint = match caller.grant_source {
655        GrantSource::NamespacesHeader => format!(
656            "add {requested_namespace} to x-aion-namespaces for subject `{}` or request a namespace listed in that header",
657            caller.subject()
658        ),
659        GrantSource::TokenClaim => format!(
660            "grant {requested_namespace} in the namespace claim of the token minted for subject `{}` or request a namespace the token grants",
661            caller.subject()
662        ),
663        // An operator holds every namespace (`can_access` always true), so this
664        // arm is never reached; keep the match exhaustive without inventing a
665        // misleading hint.
666        GrantSource::Operator => format!(
667            "subject `{}` is the operator and already holds every namespace",
668            caller.subject()
669        ),
670    };
671    ServerError::namespace_denied(format!(
672        "subject not authorized for namespace {requested_namespace}; {hint}"
673    ))
674}
675
676#[cfg(test)]
677mod tests {
678    use super::{
679        CallerIdentity, NamespaceResolver, StaticWorkflowNamespaces, WorkflowNamespaceSource,
680    };
681    use crate::config::NamespaceMode;
682    use crate::namespace::StaticScheduleNamespaces;
683    use aion_core::{ScheduleId, WorkflowId};
684
685    fn resolver(mode: NamespaceMode) -> NamespaceResolver {
686        NamespaceResolver::authorization_only(
687            mode,
688            StaticWorkflowNamespaces::default(),
689            StaticScheduleNamespaces::default(),
690        )
691    }
692
693    #[test]
694    fn shared_engine_authorizes_explicit_caller_grant() -> Result<(), Box<dyn std::error::Error>> {
695        let resolver = resolver(NamespaceMode::SharedEngine);
696        let caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
697
698        let scoped = resolver.resolve(&caller, "tenant-a")?;
699
700        assert_eq!(scoped.namespace(), "tenant-a");
701        Ok(())
702    }
703
704    /// The operator identity (auth-off operator mode) is authorized for any
705    /// namespace without enumerating it, holds the deploy grant, and signals
706    /// all-access through `all_namespaces()` while its explicit namespace set
707    /// stays empty.
708    #[test]
709    fn operator_is_authorized_for_any_namespace() -> Result<(), Box<dyn std::error::Error>> {
710        let resolver = resolver(NamespaceMode::SharedEngine);
711        let operator = CallerIdentity::operator("operator");
712
713        assert!(operator.all_namespaces());
714        assert!(operator.deploy_granted());
715        assert!(operator.namespaces().is_empty());
716
717        assert_eq!(
718            resolver.resolve(&operator, "tenant-a")?.namespace(),
719            "tenant-a"
720        );
721        assert_eq!(
722            resolver.resolve(&operator, "tenant-z")?.namespace(),
723            "tenant-z"
724        );
725        Ok(())
726    }
727
728    #[test]
729    fn shared_engine_denies_missing_caller_grant() {
730        let resolver = resolver(NamespaceMode::SharedEngine);
731        let caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
732
733        let denied = resolver.resolve(&caller, "tenant-b");
734
735        assert!(denied.is_err());
736    }
737
738    #[test]
739    fn single_tenant_authorizes_only_configured_namespace() -> Result<(), Box<dyn std::error::Error>>
740    {
741        let resolver = resolver(NamespaceMode::SingleTenant {
742            namespace: String::from("tenant-a"),
743        });
744        let caller = CallerIdentity::new("alice", [String::from("tenant-b")]);
745
746        let scoped = resolver.resolve(&caller, "tenant-a")?;
747        let denied = resolver.resolve(&caller, "tenant-b");
748
749        assert_eq!(scoped.namespace(), "tenant-a");
750        assert!(denied.is_err());
751        Ok(())
752    }
753
754    /// The denial hint must point at the knob that actually carries the
755    /// caller's grants: the development `x-aion-namespaces` header for
756    /// header-sourced identities, the token's namespace claim for identities
757    /// produced by the JWT path.
758    #[test]
759    fn denial_hint_names_the_grant_source() -> Result<(), Box<dyn std::error::Error>> {
760        let resolver = resolver(NamespaceMode::SharedEngine);
761
762        let header_caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
763        let header_denial = resolver
764            .resolve(&header_caller, "tenant-b")
765            .err()
766            .map(|error| error.to_wire_error())
767            .ok_or("expected header-sourced caller to be denied")?;
768        assert!(
769            header_denial.message.contains("x-aion-namespaces"),
770            "header-path denial must hint the dev header: {}",
771            header_denial.message
772        );
773        assert!(
774            !header_denial.message.contains("namespace claim"),
775            "header-path denial must not hint the token claim: {}",
776            header_denial.message
777        );
778
779        let token_caller = CallerIdentity::from_token_claims("alice", [String::from("tenant-a")]);
780        let token_denial = resolver
781            .resolve(&token_caller, "tenant-b")
782            .err()
783            .map(|error| error.to_wire_error())
784            .ok_or("expected token-sourced caller to be denied")?;
785        assert!(
786            token_denial.message.contains("namespace claim"),
787            "JWT-path denial must hint the token's namespace claim: {}",
788            token_denial.message
789        );
790        assert!(
791            !token_denial.message.contains("x-aion-namespaces"),
792            "JWT-path denial must not hint the dev header: {}",
793            token_denial.message
794        );
795        Ok(())
796    }
797
798    #[test]
799    fn empty_namespace_is_denied_before_scoping() {
800        let resolver = resolver(NamespaceMode::SharedEngine);
801        let caller = CallerIdentity::new("alice", [String::new()]);
802
803        let denied = resolver.resolve(&caller, "");
804
805        assert!(denied.is_err());
806    }
807
808    #[tokio::test]
809    async fn ownership_misses_are_indistinguishable_not_found()
810    -> Result<(), Box<dyn std::error::Error>> {
811        let ownership = StaticWorkflowNamespaces::default();
812        let owned = WorkflowId::new(uuid::Uuid::from_u128(1));
813        let unknown = WorkflowId::new(uuid::Uuid::from_u128(2));
814        ownership.record(owned.clone(), "tenant-a")?;
815        let resolver = NamespaceResolver::authorization_only(
816            NamespaceMode::SharedEngine,
817            ownership,
818            StaticScheduleNamespaces::default(),
819        );
820
821        resolver
822            .verify_workflow_ownership("tenant-a", &owned)
823            .await?;
824
825        // Foreign-owned and nonexistent workflows must produce byte-for-byte
826        // identical NotFound wire errors (anti-existence-leak), never
827        // NamespaceDenied.
828        let foreign = resolver
829            .verify_workflow_ownership("tenant-b", &owned)
830            .await
831            .err()
832            .map(|error| error.to_wire_error())
833            .ok_or("expected foreign-owned workflow to be rejected")?;
834        let absent = resolver
835            .verify_workflow_ownership("tenant-b", &unknown)
836            .await
837            .err()
838            .map(|error| error.to_wire_error())
839            .ok_or("expected unknown workflow to be rejected")?;
840
841        assert_eq!(foreign.code, aion_proto::WireErrorCode::NotFound);
842        assert_eq!(foreign, absent);
843        assert_eq!(foreign.message, "workflow not found in namespace tenant-b");
844
845        let absent_in_granted = resolver
846            .verify_workflow_ownership("tenant-a", &unknown)
847            .await
848            .err()
849            .map(|error| error.to_wire_error())
850            .ok_or("expected unknown workflow to be rejected in granted namespace")?;
851        assert_eq!(absent_in_granted.code, aion_proto::WireErrorCode::NotFound);
852        assert_eq!(
853            absent_in_granted.message,
854            "workflow not found in namespace tenant-a"
855        );
856        Ok(())
857    }
858
859    #[tokio::test]
860    async fn schedule_ownership_misses_are_indistinguishable_not_found()
861    -> Result<(), Box<dyn std::error::Error>> {
862        let schedule_ownership = StaticScheduleNamespaces::default();
863        let owned = ScheduleId::new(uuid::Uuid::from_u128(1));
864        let unknown = ScheduleId::new(uuid::Uuid::from_u128(2));
865        schedule_ownership.record(owned.clone(), "tenant-a")?;
866        let resolver = NamespaceResolver::authorization_only(
867            NamespaceMode::SharedEngine,
868            StaticWorkflowNamespaces::default(),
869            schedule_ownership,
870        );
871
872        resolver
873            .verify_schedule_ownership("tenant-a", &owned)
874            .await?;
875
876        // Foreign-owned and nonexistent schedules must produce byte-for-byte
877        // identical NotFound wire errors (anti-existence-leak), never
878        // NamespaceDenied.
879        let foreign = resolver
880            .verify_schedule_ownership("tenant-b", &owned)
881            .await
882            .err()
883            .map(|error| error.to_wire_error())
884            .ok_or("expected foreign-owned schedule to be rejected")?;
885        let absent = resolver
886            .verify_schedule_ownership("tenant-b", &unknown)
887            .await
888            .err()
889            .map(|error| error.to_wire_error())
890            .ok_or("expected unknown schedule to be rejected")?;
891
892        assert_eq!(foreign.code, aion_proto::WireErrorCode::NotFound);
893        assert_eq!(foreign, absent);
894        assert_eq!(foreign.message, "schedule not found in namespace tenant-b");
895
896        let absent_in_granted = resolver
897            .verify_schedule_ownership("tenant-a", &unknown)
898            .await
899            .err()
900            .map(|error| error.to_wire_error())
901            .ok_or("expected unknown schedule to be rejected in granted namespace")?;
902        assert_eq!(absent_in_granted.code, aion_proto::WireErrorCode::NotFound);
903        assert_eq!(
904            absent_in_granted.message,
905            "schedule not found in namespace tenant-a"
906        );
907        Ok(())
908    }
909
910    #[tokio::test]
911    async fn static_source_reports_recorded_namespace() -> Result<(), Box<dyn std::error::Error>> {
912        let ownership = StaticWorkflowNamespaces::default();
913        let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
914        ownership.record(workflow_id.clone(), "tenant-a")?;
915
916        assert_eq!(
917            ownership.workflow_attribution(&workflow_id).await?,
918            Some(super::WorkflowAttribution {
919                namespace: String::from("tenant-a"),
920                workflow_type: None,
921            })
922        );
923        Ok(())
924    }
925
926    #[tokio::test]
927    async fn static_source_reports_recorded_workflow_type() -> Result<(), Box<dyn std::error::Error>>
928    {
929        let ownership = StaticWorkflowNamespaces::default();
930        let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(4));
931        ownership.record_with_type(workflow_id.clone(), "tenant-a", "checkout")?;
932
933        assert_eq!(
934            ownership.workflow_attribution(&workflow_id).await?,
935            Some(super::WorkflowAttribution {
936                namespace: String::from("tenant-a"),
937                workflow_type: Some(String::from("checkout")),
938            })
939        );
940        Ok(())
941    }
942
943    /// The namespace-scoped attribution read must hide foreign and unknown
944    /// workflows identically (anti-existence-leak) while exposing the recorded
945    /// type for owned workflows.
946    #[tokio::test]
947    async fn scoped_attribution_hides_foreign_and_unknown_identically()
948    -> Result<(), Box<dyn std::error::Error>> {
949        let ownership = StaticWorkflowNamespaces::default();
950        let owned = WorkflowId::new(uuid::Uuid::from_u128(5));
951        let foreign = WorkflowId::new(uuid::Uuid::from_u128(6));
952        let unknown = WorkflowId::new(uuid::Uuid::from_u128(7));
953        ownership.record_with_type(owned.clone(), "tenant-a", "checkout")?;
954        ownership.record_with_type(foreign.clone(), "tenant-b", "checkout")?;
955        let resolver = NamespaceResolver::authorization_only(
956            NamespaceMode::SharedEngine,
957            ownership,
958            StaticScheduleNamespaces::default(),
959        );
960
961        let visible = resolver
962            .workflow_attribution("tenant-a", &owned)
963            .await?
964            .ok_or("owned workflow attribution must be visible")?;
965        assert_eq!(visible.workflow_type.as_deref(), Some("checkout"));
966        assert_eq!(
967            resolver.workflow_attribution("tenant-a", &foreign).await?,
968            None
969        );
970        assert_eq!(
971            resolver.workflow_attribution("tenant-a", &unknown).await?,
972            None
973        );
974        Ok(())
975    }
976}