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