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