Skip to main content

aion_server/namespace/
guard.rs

1//! Adapter-boundary namespace enforcement.
2
3use aion::EventFilter;
4use aion_core::{RunId, ScheduleId, WorkflowId};
5use aion_proto::{
6    FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, ProtoCancelRequest,
7    ProtoCreateScheduleRequest, ProtoDescribeWorkflowRequest, ProtoListSchedulesRequest,
8    ProtoListWorkflowsRequest, ProtoPauseRequest, ProtoQueryRequest, ProtoRegisterWorker,
9    ProtoRenameRequest, ProtoReopenRequest, ProtoResumeRequest, ProtoScheduleIdRequest,
10    ProtoSignalRequest, ProtoStartWorkflowRequest, ProtoUpdateScheduleRequest, SubscriptionRequest,
11    subscription_request,
12};
13
14use crate::error::ServerError;
15
16use super::resolver::{CallerIdentity, NamespaceResolver, ScopedEngine};
17
18/// Adapter-boundary guard shared by API, stream, and worker transports.
19#[derive(Clone)]
20pub struct NamespaceGuard {
21    resolver: NamespaceResolver,
22}
23
24impl NamespaceGuard {
25    /// Build a guard from the shared namespace resolver.
26    #[must_use]
27    pub const fn new(resolver: NamespaceResolver) -> Self {
28        Self { resolver }
29    }
30
31    /// The guard of an auth-off, shared-engine server with no static namespace
32    /// restriction: every caller is the operator and every namespace resolves.
33    /// It is what a stock single-tenant server commissions with, and what the
34    /// transport tests and the failover examples bind to a liminal listener —
35    /// named, so a listener that admits everything says so in its own
36    /// construction rather than by having no admission at all.
37    #[must_use]
38    pub fn shared_engine() -> Self {
39        Self::new(NamespaceResolver::authorization_only(
40            crate::config::NamespaceMode::SharedEngine,
41            super::StaticWorkflowNamespaces::default(),
42            super::StaticScheduleNamespaces::default(),
43        ))
44    }
45
46    /// Borrow the resolver backing this guard.
47    #[must_use]
48    pub const fn resolver(&self) -> &NamespaceResolver {
49        &self.resolver
50    }
51
52    /// Authorize and scope an operation before any engine method can be called.
53    ///
54    /// Workflow-targeted operations verify durable ownership, which reads the
55    /// target workflow's recorded history through the resolver's ownership
56    /// source.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`ServerError::Namespace`] (`namespace_denied`) when the caller
61    /// has no grant for the requested namespace or a subscription selects
62    /// another namespace. Returns a `not_found` wire error when the requested
63    /// namespace is granted but a targeted workflow is not visible in it —
64    /// foreign-owned and nonexistent workflows are deliberately
65    /// indistinguishable so the guard never leaks cross-tenant existence.
66    pub async fn scope(
67        &self,
68        caller: &CallerIdentity,
69        operation: &NamespaceOperation<'_>,
70    ) -> Result<ScopedEngine, ServerError> {
71        let requested_namespace = operation.requested_namespace();
72        let scoped = self.resolver.resolve(caller, requested_namespace)?;
73        operation.verify(&self.resolver, scoped.namespace()).await?;
74        Ok(scoped)
75    }
76
77    /// Authorize a caller for a single namespace by name, returning the
78    /// resolved namespace if the grant allows it.
79    ///
80    /// This is the SAME grant check the access hop runs ([`NamespaceResolver::resolve`]):
81    /// the operator (all-namespaces) is authorized for any name, an enumerated
82    /// caller only for a granted one, and single-tenant mode only for the
83    /// configured namespace. It carries no workflow/schedule target, so it never
84    /// reaches durable ownership — the control-plane create path (`POST
85    /// /namespaces`) authorizes namespace *existence*, not a per-resource probe.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`ServerError::Namespace`] (`namespace_denied`) when the caller
90    /// has no grant for `namespace`, so an unauthorized caller can never create
91    /// (or learn the existence of) a namespace it cannot access.
92    pub fn authorize_namespace(
93        &self,
94        caller: &CallerIdentity,
95        namespace: &str,
96    ) -> Result<String, ServerError> {
97        let scoped = self.resolver.resolve(caller, namespace)?;
98        Ok(scoped.namespace().to_owned())
99    }
100
101    /// Authorize every namespace in a worker registration's set, returning the
102    /// resolved namespaces in stable wire order with duplicates removed.
103    ///
104    /// A worker serves a SET of namespaces (NODE affinity model). Each one is an
105    /// independent correctness boundary, so the worker is authorized for it
106    /// exactly as a single-namespace operation would be: the whole registration
107    /// is denied if the caller lacks a grant for any namespace in the set. The
108    /// set must be non-empty.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`ServerError::Namespace`] (`namespace_denied`) when the set is
113    /// empty or the caller has no grant for some namespace in it.
114    pub fn scope_worker_namespaces(
115        &self,
116        caller: &CallerIdentity,
117        namespaces: &[String],
118    ) -> Result<Vec<String>, ServerError> {
119        if namespaces.is_empty() {
120            return Err(ServerError::namespace_denied(
121                "worker registration must name at least one namespace",
122            ));
123        }
124        let mut authorized: Vec<String> = Vec::with_capacity(namespaces.len());
125        for namespace in namespaces {
126            let scoped = self.resolver.resolve(caller, namespace)?;
127            let resolved = scoped.namespace().to_owned();
128            if !authorized.contains(&resolved) {
129                authorized.push(resolved);
130            }
131        }
132        Ok(authorized)
133    }
134}
135
136/// Namespace-sensitive operation described at the adapter boundary.
137pub enum NamespaceOperation<'a> {
138    /// Start workflow request.
139    StartWorkflow(&'a ProtoStartWorkflowRequest),
140    /// Signal workflow request.
141    Signal(&'a ProtoSignalRequest, WorkflowTarget<'a>),
142    /// Query workflow request.
143    Query(&'a ProtoQueryRequest, WorkflowTarget<'a>),
144    /// Cancel workflow request.
145    Cancel(&'a ProtoCancelRequest, WorkflowTarget<'a>),
146    /// Reopen workflow request.
147    Reopen(&'a ProtoReopenRequest, WorkflowTarget<'a>),
148    /// Pause workflow request (#204).
149    PauseWorkflow(&'a ProtoPauseRequest, WorkflowTarget<'a>),
150    /// Resume (paused) workflow request (#204).
151    ResumeWorkflow(&'a ProtoResumeRequest, WorkflowTarget<'a>),
152    /// Rename workflow request (#211): namespace grant + durable ownership.
153    RenameWorkflow(&'a ProtoRenameRequest, WorkflowTarget<'a>),
154    /// List workflow request.
155    ListWorkflows(&'a ProtoListWorkflowsRequest),
156    /// Count workflow request.
157    /// Describe workflow request.
158    Describe(&'a ProtoDescribeWorkflowRequest, WorkflowTarget<'a>),
159    /// Create schedule request.
160    CreateSchedule(&'a ProtoCreateScheduleRequest),
161    /// Update schedule request.
162    UpdateSchedule(&'a ProtoUpdateScheduleRequest, ScheduleTarget<'a>),
163    /// Pause schedule request.
164    PauseSchedule(&'a ProtoScheduleIdRequest, ScheduleTarget<'a>),
165    /// Resume schedule request.
166    ResumeSchedule(&'a ProtoScheduleIdRequest, ScheduleTarget<'a>),
167    /// Delete schedule request.
168    DeleteSchedule(&'a ProtoScheduleIdRequest, ScheduleTarget<'a>),
169    /// List schedules request.
170    ListSchedules(&'a ProtoListSchedulesRequest),
171    /// Describe schedule request.
172    DescribeSchedule(&'a ProtoScheduleIdRequest, ScheduleTarget<'a>),
173    /// Event subscription request.
174    Subscribe(SubscriptionScope<'a>, &'a EventFilter),
175    /// Worker registration request.
176    RegisterWorker(&'a ProtoRegisterWorker),
177    /// Mid-run intervention on a running activity (NOI-6). Namespace-scoped like
178    /// signal/cancel: the caller must hold a grant for `namespace` AND the target
179    /// workflow must be visible in it (anti-leak `not_found` otherwise).
180    Intervene {
181        /// The namespace the target workflow runs under.
182        namespace: &'a str,
183        /// The target workflow whose durable ownership is verified.
184        target: WorkflowTarget<'a>,
185    },
186    /// Read the deployed document a workflow's generation started under
187    /// (`GET /workflows/{id}/document/{hash}`): the same scope and the same
188    /// ownership verification as `Describe`, carrying no wire request because
189    /// the transport addresses the workflow by path.
190    ReadDocument {
191        /// The namespace the caller asks to be scoped to.
192        namespace: &'a str,
193        /// The workflow whose document is read.
194        target: WorkflowTarget<'a>,
195    },
196}
197
198impl<'a> NamespaceOperation<'a> {
199    /// Create a start-workflow operation descriptor.
200    #[must_use]
201    pub const fn start(request: &'a ProtoStartWorkflowRequest) -> Self {
202        Self::StartWorkflow(request)
203    }
204
205    /// Create a signal operation descriptor.
206    #[must_use]
207    pub const fn signal(request: &'a ProtoSignalRequest, target: WorkflowTarget<'a>) -> Self {
208        Self::Signal(request, target)
209    }
210
211    /// Create a query operation descriptor.
212    #[must_use]
213    pub const fn query(request: &'a ProtoQueryRequest, target: WorkflowTarget<'a>) -> Self {
214        Self::Query(request, target)
215    }
216
217    /// Create a cancel operation descriptor.
218    #[must_use]
219    pub const fn cancel(request: &'a ProtoCancelRequest, target: WorkflowTarget<'a>) -> Self {
220        Self::Cancel(request, target)
221    }
222
223    /// Create a reopen operation descriptor.
224    #[must_use]
225    pub const fn reopen(request: &'a ProtoReopenRequest, target: WorkflowTarget<'a>) -> Self {
226        Self::Reopen(request, target)
227    }
228
229    /// Create a pause-workflow operation descriptor (#204).
230    #[must_use]
231    pub const fn pause_workflow(
232        request: &'a ProtoPauseRequest,
233        target: WorkflowTarget<'a>,
234    ) -> Self {
235        Self::PauseWorkflow(request, target)
236    }
237
238    /// Create a resume-workflow operation descriptor (#204).
239    #[must_use]
240    pub const fn resume_workflow(
241        request: &'a ProtoResumeRequest,
242        target: WorkflowTarget<'a>,
243    ) -> Self {
244        Self::ResumeWorkflow(request, target)
245    }
246
247    /// Create a rename-workflow operation descriptor (#211).
248    #[must_use]
249    pub const fn rename_workflow(
250        request: &'a ProtoRenameRequest,
251        target: WorkflowTarget<'a>,
252    ) -> Self {
253        Self::RenameWorkflow(request, target)
254    }
255
256    /// Create a list-workflows operation descriptor.
257    #[must_use]
258    pub const fn list(request: &'a ProtoListWorkflowsRequest) -> Self {
259        Self::ListWorkflows(request)
260    }
261
262    /// Create a describe-workflow operation descriptor.
263    #[must_use]
264    pub const fn describe(
265        request: &'a ProtoDescribeWorkflowRequest,
266        target: WorkflowTarget<'a>,
267    ) -> Self {
268        Self::Describe(request, target)
269    }
270
271    /// Create a run-document read descriptor: scoped and verified like
272    /// [`Self::describe`].
273    #[must_use]
274    pub const fn read_document(namespace: &'a str, target: WorkflowTarget<'a>) -> Self {
275        Self::ReadDocument { namespace, target }
276    }
277
278    /// Create a create-schedule operation descriptor.
279    #[must_use]
280    pub const fn create_schedule(request: &'a ProtoCreateScheduleRequest) -> Self {
281        Self::CreateSchedule(request)
282    }
283
284    /// Create an update-schedule operation descriptor.
285    #[must_use]
286    pub const fn update_schedule(
287        request: &'a ProtoUpdateScheduleRequest,
288        target: ScheduleTarget<'a>,
289    ) -> Self {
290        Self::UpdateSchedule(request, target)
291    }
292
293    /// Create a pause-schedule operation descriptor.
294    #[must_use]
295    pub const fn pause_schedule(
296        request: &'a ProtoScheduleIdRequest,
297        target: ScheduleTarget<'a>,
298    ) -> Self {
299        Self::PauseSchedule(request, target)
300    }
301
302    /// Create a resume-schedule operation descriptor.
303    #[must_use]
304    pub const fn resume_schedule(
305        request: &'a ProtoScheduleIdRequest,
306        target: ScheduleTarget<'a>,
307    ) -> Self {
308        Self::ResumeSchedule(request, target)
309    }
310
311    /// Create a delete-schedule operation descriptor.
312    #[must_use]
313    pub const fn delete_schedule(
314        request: &'a ProtoScheduleIdRequest,
315        target: ScheduleTarget<'a>,
316    ) -> Self {
317        Self::DeleteSchedule(request, target)
318    }
319
320    /// Create a list-schedules operation descriptor.
321    #[must_use]
322    pub const fn list_schedules(request: &'a ProtoListSchedulesRequest) -> Self {
323        Self::ListSchedules(request)
324    }
325
326    /// Create a describe-schedule operation descriptor.
327    #[must_use]
328    pub const fn describe_schedule(
329        request: &'a ProtoScheduleIdRequest,
330        target: ScheduleTarget<'a>,
331    ) -> Self {
332        Self::DescribeSchedule(request, target)
333    }
334
335    /// Create a subscribe operation descriptor.
336    #[must_use]
337    pub const fn subscribe(scope: SubscriptionScope<'a>, filter: &'a EventFilter) -> Self {
338        Self::Subscribe(scope, filter)
339    }
340
341    /// Create a worker-registration operation descriptor.
342    #[must_use]
343    pub const fn register_worker(request: &'a ProtoRegisterWorker) -> Self {
344        Self::RegisterWorker(request)
345    }
346
347    /// Create a mid-run intervention operation descriptor (NOI-6).
348    #[must_use]
349    pub const fn intervene(namespace: &'a str, target: WorkflowTarget<'a>) -> Self {
350        Self::Intervene { namespace, target }
351    }
352
353    fn requested_namespace(&self) -> &str {
354        match self {
355            Self::StartWorkflow(request) => request.namespace.as_str(),
356            Self::Signal(request, _target) => request.namespace.as_str(),
357            Self::Query(request, _target) => request.namespace.as_str(),
358            Self::Cancel(request, _target) => request.namespace.as_str(),
359            Self::Reopen(request, _target) => request.namespace.as_str(),
360            Self::PauseWorkflow(request, _target) => request.namespace.as_str(),
361            Self::ResumeWorkflow(request, _target) => request.namespace.as_str(),
362            Self::RenameWorkflow(request, _target) => request.namespace.as_str(),
363            Self::ListWorkflows(request) => request.namespace.as_str(),
364            Self::Describe(request, _target) => request.namespace.as_str(),
365            Self::CreateSchedule(request) => request.namespace.as_str(),
366            Self::UpdateSchedule(request, _target) => request.namespace.as_str(),
367            Self::PauseSchedule(request, _target)
368            | Self::ResumeSchedule(request, _target)
369            | Self::DeleteSchedule(request, _target)
370            | Self::DescribeSchedule(request, _target) => request.namespace.as_str(),
371            Self::ListSchedules(request) => request.namespace.as_str(),
372            Self::Subscribe(scope, _filter) => scope.namespace(),
373            // A worker advertises a SET of namespaces; the first stands in for
374            // the single-scope path. Multi-namespace worker registration
375            // authorizes every namespace via `scope_worker_namespaces`, so this
376            // is only reached if a caller routes a worker registration through
377            // the single-scope `scope` API. Empty set => empty string, which
378            // the resolver rejects as an unauthorized namespace.
379            Self::RegisterWorker(request) => request.namespaces.first().map_or("", String::as_str),
380            Self::Intervene { namespace, .. } | Self::ReadDocument { namespace, .. } => namespace,
381        }
382    }
383
384    async fn verify(
385        &self,
386        resolver: &NamespaceResolver,
387        authorized_namespace: &str,
388    ) -> Result<(), ServerError> {
389        match self {
390            // NOI-6 `Intervene` verifies durable workflow ownership exactly like
391            // signal/cancel, so a caller cannot probe or steer a foreign workflow.
392            Self::Signal(_, target)
393            | Self::Query(_, target)
394            | Self::Cancel(_, target)
395            | Self::Reopen(_, target)
396            | Self::PauseWorkflow(_, target)
397            | Self::ResumeWorkflow(_, target)
398            | Self::RenameWorkflow(_, target)
399            | Self::Describe(_, target)
400            | Self::Intervene { target, .. }
401            | Self::ReadDocument { target, .. } => {
402                target.verify(resolver, authorized_namespace).await
403            }
404            Self::UpdateSchedule(_, target)
405            | Self::PauseSchedule(_, target)
406            | Self::ResumeSchedule(_, target)
407            | Self::DeleteSchedule(_, target)
408            | Self::DescribeSchedule(_, target) => {
409                target.verify(resolver, authorized_namespace).await
410            }
411            Self::Subscribe(scope, filter) => {
412                scope.verify(resolver, authorized_namespace, filter).await
413            }
414            // CreateSchedule needs no target verification: the schedule id is
415            // server-generated at creation, so a create can never collide with
416            // or probe another tenant's resource; the handler stamps the
417            // authorized namespace into the recorded config. ListSchedules is
418            // grant-checked here and result-filtered in the handler, exactly
419            // like workflow list.
420            Self::StartWorkflow(_)
421            | Self::ListWorkflows(_)
422            | Self::CreateSchedule(_)
423            | Self::ListSchedules(_)
424            | Self::RegisterWorker(_) => Ok(()),
425        }
426    }
427}
428
429/// Target workflow identifiers decoded by a handler before the engine call.
430#[derive(Clone, Copy)]
431pub struct WorkflowTarget<'a> {
432    workflow_id: &'a WorkflowId,
433    run_id: Option<&'a RunId>,
434}
435
436impl<'a> WorkflowTarget<'a> {
437    /// Build a target for operations that require workflow and run identifiers.
438    #[must_use]
439    pub const fn with_run(workflow_id: &'a WorkflowId, run_id: &'a RunId) -> Self {
440        Self {
441            workflow_id,
442            run_id: Some(run_id),
443        }
444    }
445
446    /// Build a target for operations that identify only a workflow.
447    #[must_use]
448    pub const fn workflow(workflow_id: &'a WorkflowId) -> Self {
449        Self {
450            workflow_id,
451            run_id: None,
452        }
453    }
454
455    /// Target workflow id.
456    #[must_use]
457    pub const fn workflow_id(&self) -> &WorkflowId {
458        self.workflow_id
459    }
460
461    /// Optional target run id.
462    #[must_use]
463    pub const fn run_id(&self) -> Option<&RunId> {
464        self.run_id
465    }
466
467    async fn verify(
468        &self,
469        resolver: &NamespaceResolver,
470        namespace: &str,
471    ) -> Result<(), ServerError> {
472        resolver
473            .verify_workflow_ownership(namespace, self.workflow_id)
474            .await
475    }
476}
477
478/// Target schedule identifier decoded by a handler before the engine call.
479#[derive(Clone, Copy)]
480pub struct ScheduleTarget<'a> {
481    schedule_id: &'a ScheduleId,
482}
483
484impl<'a> ScheduleTarget<'a> {
485    /// Build a target for operations that identify one schedule.
486    #[must_use]
487    pub const fn schedule(schedule_id: &'a ScheduleId) -> Self {
488        Self { schedule_id }
489    }
490
491    /// Target schedule id.
492    #[must_use]
493    pub const fn schedule_id(&self) -> &ScheduleId {
494        self.schedule_id
495    }
496
497    async fn verify(
498        &self,
499        resolver: &NamespaceResolver,
500        namespace: &str,
501    ) -> Result<(), ServerError> {
502        resolver
503            .verify_schedule_ownership(namespace, self.schedule_id)
504            .await
505    }
506}
507
508/// Event subscription namespace scope decoded by a stream adapter.
509pub enum SubscriptionScope<'a> {
510    /// Events for one workflow.
511    PerWorkflow(&'a PerWorkflowSubscription, WorkflowTarget<'a>),
512    /// Filtered events in the caller namespace.
513    Filtered(&'a FilteredSubscription),
514    /// Firehose events in the caller namespace.
515    Firehose(&'a FirehoseSubscription),
516}
517
518impl<'a> SubscriptionScope<'a> {
519    /// Decode the namespace scope from a subscription request after the handler
520    /// has decoded any workflow identifiers required by the selected variant.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`ServerError::Namespace`] when the request omits the subscription
525    /// variant.
526    pub fn from_request(
527        request: &'a SubscriptionRequest,
528        workflow_target: Option<WorkflowTarget<'a>>,
529    ) -> Result<Self, ServerError> {
530        match &request.subscription {
531            Some(subscription_request::Subscription::PerWorkflow(subscription)) => {
532                let target = workflow_target.ok_or_else(|| {
533                    ServerError::namespace_denied(
534                        "per-workflow subscription target must be decoded before guard scope",
535                    )
536                })?;
537                Ok(Self::PerWorkflow(subscription, target))
538            }
539            Some(subscription_request::Subscription::Filtered(subscription)) => {
540                Ok(Self::Filtered(subscription))
541            }
542            Some(subscription_request::Subscription::Firehose(subscription)) => {
543                Ok(Self::Firehose(subscription))
544            }
545            // The cluster subscription is deployment-scoped, not namespace-scoped:
546            // it is authorized by the deploy grant in the cluster channel, never
547            // through this namespace `SubscriptionScope`. It is dispatched before
548            // the workflow guard path runs, so reaching here is a routing bug.
549            Some(subscription_request::Subscription::Cluster(_)) => {
550                Err(ServerError::namespace_denied(
551                    "cluster subscription is deployment-scoped and not served by the namespace guard",
552                ))
553            }
554            // The transcript subscription (NOI-5b) is dispatched to its own
555            // socket before the workflow guard path runs and builds its scope
556            // directly, so reaching this generic mapper with one is a routing bug.
557            Some(subscription_request::Subscription::Transcript(_)) => {
558                Err(ServerError::namespace_denied(
559                    "transcript subscription is served by the transcript channel, not this mapper",
560                ))
561            }
562            None => Err(ServerError::namespace_denied(
563                "subscription request must name a namespace",
564            )),
565        }
566    }
567
568    fn namespace(&self) -> &str {
569        match self {
570            Self::PerWorkflow(subscription, _target) => subscription.namespace.as_str(),
571            Self::Filtered(subscription) => subscription.namespace.as_str(),
572            Self::Firehose(subscription) => subscription.namespace.as_str(),
573        }
574    }
575
576    async fn verify(
577        &self,
578        resolver: &NamespaceResolver,
579        namespace: &str,
580        filter: &EventFilter,
581    ) -> Result<(), ServerError> {
582        match self {
583            Self::PerWorkflow(_subscription, target) => {
584                verify_subscription_filter_target(filter, Some(*target), resolver, namespace).await
585            }
586            Self::Filtered(subscription) => {
587                verify_namespace_selector(subscription.namespace_selector.as_deref(), namespace)?;
588                verify_subscription_filter_target(filter, None, resolver, namespace).await
589            }
590            Self::Firehose(_) => {
591                verify_subscription_filter_target(filter, None, resolver, namespace).await
592            }
593        }
594    }
595}
596
597fn verify_namespace_selector(selector: Option<&str>, namespace: &str) -> Result<(), ServerError> {
598    match selector {
599        Some(selector) if selector != namespace => Err(ServerError::namespace_denied(
600            "subscription namespace selector is not authorized",
601        )),
602        Some(_) | None => Ok(()),
603    }
604}
605
606async fn verify_subscription_filter_target(
607    filter: &EventFilter,
608    explicit_target: Option<WorkflowTarget<'_>>,
609    resolver: &NamespaceResolver,
610    namespace: &str,
611) -> Result<(), ServerError> {
612    if let Some(target) = explicit_target {
613        if filter
614            .workflow_id
615            .as_ref()
616            .is_some_and(|workflow_id| workflow_id != target.workflow_id())
617        {
618            return Err(ServerError::namespace_denied(
619                "subscription filter workflow does not match decoded target",
620            ));
621        }
622        target.verify(resolver, namespace).await
623    } else if let Some(workflow_id) = &filter.workflow_id {
624        resolver
625            .verify_workflow_ownership(namespace, workflow_id)
626            .await
627    } else {
628        Ok(())
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use std::sync::atomic::{AtomicUsize, Ordering};
635    use std::sync::{Arc, Mutex};
636
637    use aion_core::{RunId, ScheduleId, WorkflowId};
638    use aion_proto::{
639        FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, ProtoCancelRequest,
640        ProtoCreateScheduleRequest, ProtoDescribeWorkflowRequest, ProtoListSchedulesRequest,
641        ProtoListWorkflowsRequest, ProtoQueryRequest, ProtoRegisterWorker, ProtoReopenRequest,
642        ProtoScheduleIdRequest, ProtoSignalRequest, ProtoStartWorkflowRequest,
643        ProtoUpdateScheduleRequest,
644    };
645    use async_trait::async_trait;
646
647    use super::{
648        NamespaceGuard, NamespaceOperation, ScheduleTarget, SubscriptionScope, WorkflowTarget,
649    };
650    use crate::config::NamespaceMode;
651    use crate::error::ServerError;
652    use crate::namespace::{
653        CallerIdentity, NamespaceResolver, ScheduleNamespaceSource, StaticScheduleNamespaces,
654        StaticWorkflowNamespaces,
655    };
656
657    struct RecordingFakeEngine {
658        calls: Mutex<Vec<&'static str>>,
659    }
660
661    impl RecordingFakeEngine {
662        fn new() -> Self {
663            Self {
664                calls: Mutex::new(Vec::new()),
665            }
666        }
667
668        fn calls(&self) -> Result<Vec<&'static str>, Box<dyn std::error::Error>> {
669            let calls = self
670                .calls
671                .lock()
672                .map_err(|_| "fake engine calls lock poisoned")?;
673            Ok(calls.clone())
674        }
675    }
676
677    /// Schedule ownership source that counts every verification consult so
678    /// tests can prove whether the guard reached durable ownership at all:
679    /// ownership misses must consult exactly once per operation, while grant
680    /// denials must short-circuit before any consult.
681    #[derive(Clone)]
682    struct CountingScheduleNamespaces {
683        inner: StaticScheduleNamespaces,
684        calls: Arc<AtomicUsize>,
685    }
686
687    impl CountingScheduleNamespaces {
688        fn wrapping(inner: StaticScheduleNamespaces) -> Self {
689            Self {
690                inner,
691                calls: Arc::new(AtomicUsize::new(0)),
692            }
693        }
694
695        fn calls(&self) -> usize {
696            self.calls.load(Ordering::SeqCst)
697        }
698    }
699
700    #[async_trait]
701    impl ScheduleNamespaceSource for CountingScheduleNamespaces {
702        async fn schedule_namespace(
703            &self,
704            schedule_id: &ScheduleId,
705        ) -> Result<Option<String>, ServerError> {
706            self.calls.fetch_add(1, Ordering::SeqCst);
707            self.inner.schedule_namespace(schedule_id).await
708        }
709    }
710
711    fn guard_with_ownership(ownership: StaticWorkflowNamespaces) -> NamespaceGuard {
712        let resolver = NamespaceResolver::authorization_only(
713            NamespaceMode::SharedEngine,
714            ownership,
715            StaticScheduleNamespaces::default(),
716        );
717        NamespaceGuard::new(resolver)
718    }
719
720    fn guard_with_schedule_ownership(
721        schedule_ownership: impl ScheduleNamespaceSource + 'static,
722    ) -> NamespaceGuard {
723        let resolver = NamespaceResolver::authorization_only(
724            NamespaceMode::SharedEngine,
725            StaticWorkflowNamespaces::default(),
726            schedule_ownership,
727        );
728        NamespaceGuard::new(resolver)
729    }
730
731    fn caller() -> CallerIdentity {
732        CallerIdentity::new("alice", [String::from("tenant-a")])
733    }
734
735    fn workflow_ids() -> (WorkflowId, RunId) {
736        (
737            WorkflowId::new(uuid::Uuid::from_u128(1)),
738            RunId::new(uuid::Uuid::from_u128(2)),
739        )
740    }
741
742    #[tokio::test]
743    async fn denied_targeted_operations_do_not_call_engine()
744    -> Result<(), Box<dyn std::error::Error>> {
745        let (workflow_id, run_id) = workflow_ids();
746        let ownership = StaticWorkflowNamespaces::default();
747        ownership.record(workflow_id.clone(), "tenant-b")?;
748        let guard = guard_with_ownership(ownership);
749        let fake = RecordingFakeEngine::new();
750        let target = WorkflowTarget::with_run(&workflow_id, &run_id);
751
752        let signal = ProtoSignalRequest {
753            namespace: String::from("tenant-a"),
754            workflow_id: None,
755            run_id: None,
756            signal_name: String::from("ship"),
757            payload: None,
758        };
759        let query = ProtoQueryRequest {
760            namespace: String::from("tenant-a"),
761            workflow_id: None,
762            run_id: None,
763            query_name: String::from("state"),
764            arguments: None,
765        };
766        let cancel = ProtoCancelRequest {
767            namespace: String::from("tenant-a"),
768            workflow_id: None,
769            run_id: None,
770            reason: String::from("operator"),
771        };
772        let reopen = ProtoReopenRequest {
773            namespace: String::from("tenant-a"),
774            workflow_id: None,
775            run_id: None,
776        };
777        let describe = ProtoDescribeWorkflowRequest {
778            namespace: String::from("tenant-a"),
779            workflow_id: None,
780            run_id: None,
781            include_history: false,
782        };
783
784        let operations = [
785            NamespaceOperation::signal(&signal, target),
786            NamespaceOperation::query(&query, target),
787            NamespaceOperation::cancel(&cancel, target),
788            NamespaceOperation::reopen(&reopen, target),
789            NamespaceOperation::describe(&describe, target),
790        ];
791
792        for operation in operations {
793            let result = guard.scope(&caller(), &operation).await;
794            // Ownership miss in a granted namespace is NotFound, not
795            // NamespaceDenied: cross-tenant probes must be indistinguishable
796            // from nonexistent workflows.
797            assert_eq!(
798                result.err().map(|error| error.to_wire_error().code),
799                Some(aion_proto::WireErrorCode::NotFound)
800            );
801        }
802        assert!(fake.calls()?.is_empty());
803        Ok(())
804    }
805
806    #[tokio::test]
807    async fn denied_list_and_worker_scope_do_not_call_engine()
808    -> Result<(), Box<dyn std::error::Error>> {
809        let (workflow_id, _run_id) = workflow_ids();
810        let ownership = StaticWorkflowNamespaces::default();
811        ownership.record(workflow_id, "tenant-b")?;
812        let guard = guard_with_ownership(ownership);
813        let fake = RecordingFakeEngine::new();
814
815        let list = ProtoListWorkflowsRequest {
816            namespace: String::from("tenant-b"),
817            request: None,
818        };
819        let worker = ProtoRegisterWorker {
820            namespaces: vec![String::from("tenant-b")],
821            activity_types: vec![String::from("ship")],
822            task_queue: String::new(),
823            node: String::new(),
824            activities: Vec::new(),
825            identity: String::new(),
826            instance: None,
827        };
828
829        assert!(
830            guard
831                .scope(&caller(), &NamespaceOperation::list(&list))
832                .await
833                .is_err()
834        );
835        assert!(
836            guard
837                .scope(&caller(), &NamespaceOperation::register_worker(&worker),)
838                .await
839                .is_err()
840        );
841        assert!(fake.calls()?.is_empty());
842        Ok(())
843    }
844
845    #[tokio::test]
846    async fn denied_subscriptions_do_not_call_engine() -> Result<(), Box<dyn std::error::Error>> {
847        let (workflow_id, run_id) = workflow_ids();
848        let ownership = StaticWorkflowNamespaces::default();
849        ownership.record(workflow_id.clone(), "tenant-b")?;
850        let guard = guard_with_ownership(ownership);
851        let fake = RecordingFakeEngine::new();
852        let event_filter = aion::EventFilter::default();
853
854        let filtered = FilteredSubscription {
855            namespace: String::from("tenant-a"),
856            workflow_type: None,
857            status: None,
858            namespace_selector: Some(String::from("tenant-b")),
859        };
860        let filtered_by_workflow = FilteredSubscription {
861            namespace: String::from("tenant-a"),
862            workflow_type: None,
863            status: None,
864            namespace_selector: None,
865        };
866        let per_workflow = PerWorkflowSubscription {
867            namespace: String::from("tenant-a"),
868            workflow_id: None,
869            resume_from_seq: None,
870        };
871        let cross_namespace_filter = aion::EventFilter {
872            workflow_id: Some(workflow_id.clone()),
873            run: None,
874            family: None,
875        };
876        let firehose = FirehoseSubscription {
877            namespace: String::from("tenant-b"),
878        };
879
880        let target = WorkflowTarget::with_run(&workflow_id, &run_id);
881        // Namespace-grant and selector failures stay NamespaceDenied; a
882        // workflow-targeted subscription that misses ownership in a granted
883        // namespace is NotFound (anti-existence-leak).
884        let denied_subscriptions = [
885            (
886                NamespaceOperation::subscribe(
887                    SubscriptionScope::Filtered(&filtered),
888                    &event_filter,
889                ),
890                aion_proto::WireErrorCode::NamespaceDenied,
891            ),
892            (
893                NamespaceOperation::subscribe(
894                    SubscriptionScope::Filtered(&filtered_by_workflow),
895                    &cross_namespace_filter,
896                ),
897                aion_proto::WireErrorCode::NotFound,
898            ),
899            (
900                NamespaceOperation::subscribe(
901                    SubscriptionScope::PerWorkflow(&per_workflow, target),
902                    &cross_namespace_filter,
903                ),
904                aion_proto::WireErrorCode::NotFound,
905            ),
906            (
907                NamespaceOperation::subscribe(
908                    SubscriptionScope::Firehose(&firehose),
909                    &event_filter,
910                ),
911                aion_proto::WireErrorCode::NamespaceDenied,
912            ),
913        ];
914
915        for (operation, expected_code) in &denied_subscriptions {
916            assert_eq!(
917                guard
918                    .scope(&caller(), operation)
919                    .await
920                    .err()
921                    .map(|error| error.to_wire_error().code)
922                    .as_ref(),
923                Some(expected_code)
924            );
925        }
926        assert!(fake.calls()?.is_empty());
927        Ok(())
928    }
929
930    fn schedule_id() -> ScheduleId {
931        ScheduleId::new(uuid::Uuid::from_u128(9))
932    }
933
934    fn schedule_id_request(namespace: &str) -> ProtoScheduleIdRequest {
935        ProtoScheduleIdRequest {
936            namespace: namespace.to_owned(),
937            schedule_id: None,
938        }
939    }
940
941    #[tokio::test]
942    async fn schedule_ownership_misses_are_not_found_and_do_not_call_engine()
943    -> Result<(), Box<dyn std::error::Error>> {
944        let schedule_id = schedule_id();
945        let schedule_ownership = StaticScheduleNamespaces::default();
946        schedule_ownership.record(schedule_id.clone(), "tenant-b")?;
947        let counting = CountingScheduleNamespaces::wrapping(schedule_ownership);
948        // The resolver carries no engine handle at all (`authorization_only`),
949        // so every operation that errors here provably erred before any engine
950        // access could exist.
951        let guard = guard_with_schedule_ownership(counting.clone());
952        let target = ScheduleTarget::schedule(&schedule_id);
953
954        let update = ProtoUpdateScheduleRequest {
955            namespace: String::from("tenant-a"),
956            schedule_id: None,
957            config: None,
958        };
959        let id_request = schedule_id_request("tenant-a");
960
961        let operations = [
962            NamespaceOperation::update_schedule(&update, target),
963            NamespaceOperation::pause_schedule(&id_request, target),
964            NamespaceOperation::resume_schedule(&id_request, target),
965            NamespaceOperation::delete_schedule(&id_request, target),
966            NamespaceOperation::describe_schedule(&id_request, target),
967        ];
968        let operation_count = operations.len();
969
970        for operation in operations {
971            let result = guard.scope(&caller(), &operation).await;
972            // Ownership miss in a granted namespace is NotFound, not
973            // NamespaceDenied: cross-tenant probes must be indistinguishable
974            // from nonexistent schedules.
975            let error = result
976                .err()
977                .map(|error| error.to_wire_error())
978                .ok_or("expected foreign-owned schedule to be rejected")?;
979            assert_eq!(error.code, aion_proto::WireErrorCode::NotFound);
980            assert_eq!(error.message, "schedule not found in namespace tenant-a");
981        }
982        // Durable ownership was consulted exactly once per targeted operation:
983        // the NotFound came from the verification step, not from skipping it.
984        assert_eq!(counting.calls(), operation_count);
985        Ok(())
986    }
987
988    #[tokio::test]
989    async fn ungranted_schedule_operations_are_namespace_denied()
990    -> Result<(), Box<dyn std::error::Error>> {
991        let schedule_id = schedule_id();
992        let schedule_ownership = StaticScheduleNamespaces::default();
993        schedule_ownership.record(schedule_id.clone(), "tenant-b")?;
994        let counting = CountingScheduleNamespaces::wrapping(schedule_ownership);
995        let guard = guard_with_schedule_ownership(counting.clone());
996        let target = ScheduleTarget::schedule(&schedule_id);
997
998        let create = ProtoCreateScheduleRequest {
999            namespace: String::from("tenant-b"),
1000            config: None,
1001        };
1002        let update = ProtoUpdateScheduleRequest {
1003            namespace: String::from("tenant-b"),
1004            schedule_id: None,
1005            config: None,
1006        };
1007        let id_request = schedule_id_request("tenant-b");
1008        let list = ProtoListSchedulesRequest {
1009            namespace: String::from("tenant-b"),
1010        };
1011
1012        let operations = [
1013            NamespaceOperation::create_schedule(&create),
1014            NamespaceOperation::update_schedule(&update, target),
1015            NamespaceOperation::pause_schedule(&id_request, target),
1016            NamespaceOperation::resume_schedule(&id_request, target),
1017            NamespaceOperation::delete_schedule(&id_request, target),
1018            NamespaceOperation::describe_schedule(&id_request, target),
1019            NamespaceOperation::list_schedules(&list),
1020        ];
1021
1022        // No grant for the requested namespace is NamespaceDenied for all
1023        // seven schedule operations — even when the target schedule really is
1024        // owned by that namespace, the grant check decides first.
1025        for operation in operations {
1026            let result = guard.scope(&caller(), &operation).await;
1027            assert_eq!(
1028                result.err().map(|error| error.to_wire_error().code),
1029                Some(aion_proto::WireErrorCode::NamespaceDenied)
1030            );
1031        }
1032        // The grant check short-circuits before target verification: durable
1033        // ownership must never be consulted for an ungranted namespace.
1034        assert_eq!(counting.calls(), 0);
1035        Ok(())
1036    }
1037
1038    #[tokio::test]
1039    async fn granted_schedule_create_and_list_return_scoped_engine()
1040    -> Result<(), Box<dyn std::error::Error>> {
1041        let guard = guard_with_schedule_ownership(StaticScheduleNamespaces::default());
1042        let create = ProtoCreateScheduleRequest {
1043            namespace: String::from("tenant-a"),
1044            config: None,
1045        };
1046        let list = ProtoListSchedulesRequest {
1047            namespace: String::from("tenant-a"),
1048        };
1049
1050        let scoped_create = guard
1051            .scope(&caller(), &NamespaceOperation::create_schedule(&create))
1052            .await?;
1053        let scoped_list = guard
1054            .scope(&caller(), &NamespaceOperation::list_schedules(&list))
1055            .await?;
1056
1057        assert_eq!(scoped_create.namespace(), "tenant-a");
1058        assert_eq!(scoped_list.namespace(), "tenant-a");
1059        Ok(())
1060    }
1061
1062    #[tokio::test]
1063    async fn authorized_start_returns_scoped_engine() -> Result<(), Box<dyn std::error::Error>> {
1064        let guard = guard_with_ownership(StaticWorkflowNamespaces::default());
1065        let request = ProtoStartWorkflowRequest {
1066            namespace: String::from("tenant-a"),
1067            workflow_type: String::from("checkout"),
1068            input: None,
1069            routing_key: None,
1070            task_queue: None,
1071            display_name: None,
1072        };
1073
1074        let scoped = guard
1075            .scope(&caller(), &NamespaceOperation::start(&request))
1076            .await?;
1077
1078        assert_eq!(scoped.namespace(), "tenant-a");
1079        Ok(())
1080    }
1081
1082    #[tokio::test]
1083    async fn single_tenant_mode_authorizes_configured_namespace()
1084    -> Result<(), Box<dyn std::error::Error>> {
1085        let resolver = NamespaceResolver::authorization_only(
1086            NamespaceMode::SingleTenant {
1087                namespace: String::from("tenant-a"),
1088            },
1089            StaticWorkflowNamespaces::default(),
1090            StaticScheduleNamespaces::default(),
1091        );
1092        let guard = NamespaceGuard::new(resolver);
1093        let request = ProtoRegisterWorker {
1094            namespaces: vec![String::from("tenant-a")],
1095            activity_types: Vec::new(),
1096            task_queue: String::new(),
1097            node: String::new(),
1098            activities: Vec::new(),
1099            identity: String::new(),
1100            instance: None,
1101        };
1102
1103        let scoped = guard
1104            .scope(
1105                &CallerIdentity::new("single-tenant", Vec::<String>::new()),
1106                &NamespaceOperation::register_worker(&request),
1107            )
1108            .await?;
1109
1110        assert_eq!(scoped.namespace(), "tenant-a");
1111        Ok(())
1112    }
1113}