Skip to main content

aion_server/namespace/
guard.rs

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