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            arguments: None,
726        };
727        let cancel = ProtoCancelRequest {
728            namespace: String::from("tenant-a"),
729            workflow_id: None,
730            run_id: None,
731            reason: String::from("operator"),
732        };
733        let reopen = ProtoReopenRequest {
734            namespace: String::from("tenant-a"),
735            workflow_id: None,
736            run_id: None,
737        };
738        let describe = ProtoDescribeWorkflowRequest {
739            namespace: String::from("tenant-a"),
740            workflow_id: None,
741            run_id: None,
742            include_history: false,
743        };
744
745        let operations = [
746            NamespaceOperation::signal(&signal, target),
747            NamespaceOperation::query(&query, target),
748            NamespaceOperation::cancel(&cancel, target),
749            NamespaceOperation::reopen(&reopen, target),
750            NamespaceOperation::describe(&describe, target),
751        ];
752
753        for operation in operations {
754            let result = guard.scope(&caller(), &operation).await;
755            // Ownership miss in a granted namespace is NotFound, not
756            // NamespaceDenied: cross-tenant probes must be indistinguishable
757            // from nonexistent workflows.
758            assert_eq!(
759                result.err().map(|error| error.to_wire_error().code),
760                Some(aion_proto::WireErrorCode::NotFound)
761            );
762        }
763        assert!(fake.calls()?.is_empty());
764        Ok(())
765    }
766
767    #[tokio::test]
768    async fn denied_list_and_worker_scope_do_not_call_engine()
769    -> Result<(), Box<dyn std::error::Error>> {
770        let (workflow_id, _run_id) = workflow_ids();
771        let ownership = StaticWorkflowNamespaces::default();
772        ownership.record(workflow_id, "tenant-b")?;
773        let guard = guard_with_ownership(ownership);
774        let fake = RecordingFakeEngine::new();
775        let filter = WorkflowFilter::default();
776
777        let list = ProtoListWorkflowsRequest {
778            namespace: String::from("tenant-b"),
779            filter: None,
780        };
781        let worker = ProtoRegisterWorker {
782            namespaces: vec![String::from("tenant-b")],
783            activity_types: vec![String::from("ship")],
784            task_queue: String::new(),
785            node: String::new(),
786            activities: Vec::new(),
787            identity: String::new(),
788            instance: None,
789        };
790
791        assert!(
792            guard
793                .scope(&caller(), &NamespaceOperation::list(&list, &filter))
794                .await
795                .is_err()
796        );
797        assert!(
798            guard
799                .scope(&caller(), &NamespaceOperation::register_worker(&worker),)
800                .await
801                .is_err()
802        );
803        assert!(fake.calls()?.is_empty());
804        Ok(())
805    }
806
807    #[tokio::test]
808    async fn denied_subscriptions_do_not_call_engine() -> Result<(), Box<dyn std::error::Error>> {
809        let (workflow_id, run_id) = workflow_ids();
810        let ownership = StaticWorkflowNamespaces::default();
811        ownership.record(workflow_id.clone(), "tenant-b")?;
812        let guard = guard_with_ownership(ownership);
813        let fake = RecordingFakeEngine::new();
814        let event_filter = aion::EventFilter::default();
815
816        let filtered = FilteredSubscription {
817            namespace: String::from("tenant-a"),
818            workflow_type: None,
819            status: None,
820            namespace_selector: Some(String::from("tenant-b")),
821        };
822        let filtered_by_workflow = FilteredSubscription {
823            namespace: String::from("tenant-a"),
824            workflow_type: None,
825            status: None,
826            namespace_selector: None,
827        };
828        let per_workflow = PerWorkflowSubscription {
829            namespace: String::from("tenant-a"),
830            workflow_id: None,
831            resume_from_seq: None,
832        };
833        let cross_namespace_filter = aion::EventFilter {
834            workflow_id: Some(workflow_id.clone()),
835            run: None,
836            family: None,
837        };
838        let firehose = FirehoseSubscription {
839            namespace: String::from("tenant-b"),
840        };
841
842        let target = WorkflowTarget::with_run(&workflow_id, &run_id);
843        // Namespace-grant and selector failures stay NamespaceDenied; a
844        // workflow-targeted subscription that misses ownership in a granted
845        // namespace is NotFound (anti-existence-leak).
846        let denied_subscriptions = [
847            (
848                NamespaceOperation::subscribe(
849                    SubscriptionScope::Filtered(&filtered),
850                    &event_filter,
851                ),
852                aion_proto::WireErrorCode::NamespaceDenied,
853            ),
854            (
855                NamespaceOperation::subscribe(
856                    SubscriptionScope::Filtered(&filtered_by_workflow),
857                    &cross_namespace_filter,
858                ),
859                aion_proto::WireErrorCode::NotFound,
860            ),
861            (
862                NamespaceOperation::subscribe(
863                    SubscriptionScope::PerWorkflow(&per_workflow, target),
864                    &cross_namespace_filter,
865                ),
866                aion_proto::WireErrorCode::NotFound,
867            ),
868            (
869                NamespaceOperation::subscribe(
870                    SubscriptionScope::Firehose(&firehose),
871                    &event_filter,
872                ),
873                aion_proto::WireErrorCode::NamespaceDenied,
874            ),
875        ];
876
877        for (operation, expected_code) in &denied_subscriptions {
878            assert_eq!(
879                guard
880                    .scope(&caller(), operation)
881                    .await
882                    .err()
883                    .map(|error| error.to_wire_error().code)
884                    .as_ref(),
885                Some(expected_code)
886            );
887        }
888        assert!(fake.calls()?.is_empty());
889        Ok(())
890    }
891
892    fn schedule_id() -> ScheduleId {
893        ScheduleId::new(uuid::Uuid::from_u128(9))
894    }
895
896    fn schedule_id_request(namespace: &str) -> ProtoScheduleIdRequest {
897        ProtoScheduleIdRequest {
898            namespace: namespace.to_owned(),
899            schedule_id: None,
900        }
901    }
902
903    #[tokio::test]
904    async fn schedule_ownership_misses_are_not_found_and_do_not_call_engine()
905    -> Result<(), Box<dyn std::error::Error>> {
906        let schedule_id = schedule_id();
907        let schedule_ownership = StaticScheduleNamespaces::default();
908        schedule_ownership.record(schedule_id.clone(), "tenant-b")?;
909        let counting = CountingScheduleNamespaces::wrapping(schedule_ownership);
910        // The resolver carries no engine handle at all (`authorization_only`),
911        // so every operation that errors here provably erred before any engine
912        // access could exist.
913        let guard = guard_with_schedule_ownership(counting.clone());
914        let target = ScheduleTarget::schedule(&schedule_id);
915
916        let update = ProtoUpdateScheduleRequest {
917            namespace: String::from("tenant-a"),
918            schedule_id: None,
919            config: None,
920        };
921        let id_request = schedule_id_request("tenant-a");
922
923        let operations = [
924            NamespaceOperation::update_schedule(&update, target),
925            NamespaceOperation::pause_schedule(&id_request, target),
926            NamespaceOperation::resume_schedule(&id_request, target),
927            NamespaceOperation::delete_schedule(&id_request, target),
928            NamespaceOperation::describe_schedule(&id_request, target),
929        ];
930        let operation_count = operations.len();
931
932        for operation in operations {
933            let result = guard.scope(&caller(), &operation).await;
934            // Ownership miss in a granted namespace is NotFound, not
935            // NamespaceDenied: cross-tenant probes must be indistinguishable
936            // from nonexistent schedules.
937            let error = result
938                .err()
939                .map(|error| error.to_wire_error())
940                .ok_or("expected foreign-owned schedule to be rejected")?;
941            assert_eq!(error.code, aion_proto::WireErrorCode::NotFound);
942            assert_eq!(error.message, "schedule not found in namespace tenant-a");
943        }
944        // Durable ownership was consulted exactly once per targeted operation:
945        // the NotFound came from the verification step, not from skipping it.
946        assert_eq!(counting.calls(), operation_count);
947        Ok(())
948    }
949
950    #[tokio::test]
951    async fn ungranted_schedule_operations_are_namespace_denied()
952    -> Result<(), Box<dyn std::error::Error>> {
953        let schedule_id = schedule_id();
954        let schedule_ownership = StaticScheduleNamespaces::default();
955        schedule_ownership.record(schedule_id.clone(), "tenant-b")?;
956        let counting = CountingScheduleNamespaces::wrapping(schedule_ownership);
957        let guard = guard_with_schedule_ownership(counting.clone());
958        let target = ScheduleTarget::schedule(&schedule_id);
959
960        let create = ProtoCreateScheduleRequest {
961            namespace: String::from("tenant-b"),
962            config: None,
963        };
964        let update = ProtoUpdateScheduleRequest {
965            namespace: String::from("tenant-b"),
966            schedule_id: None,
967            config: None,
968        };
969        let id_request = schedule_id_request("tenant-b");
970        let list = ProtoListSchedulesRequest {
971            namespace: String::from("tenant-b"),
972        };
973
974        let operations = [
975            NamespaceOperation::create_schedule(&create),
976            NamespaceOperation::update_schedule(&update, target),
977            NamespaceOperation::pause_schedule(&id_request, target),
978            NamespaceOperation::resume_schedule(&id_request, target),
979            NamespaceOperation::delete_schedule(&id_request, target),
980            NamespaceOperation::describe_schedule(&id_request, target),
981            NamespaceOperation::list_schedules(&list),
982        ];
983
984        // No grant for the requested namespace is NamespaceDenied for all
985        // seven schedule operations — even when the target schedule really is
986        // owned by that namespace, the grant check decides first.
987        for operation in operations {
988            let result = guard.scope(&caller(), &operation).await;
989            assert_eq!(
990                result.err().map(|error| error.to_wire_error().code),
991                Some(aion_proto::WireErrorCode::NamespaceDenied)
992            );
993        }
994        // The grant check short-circuits before target verification: durable
995        // ownership must never be consulted for an ungranted namespace.
996        assert_eq!(counting.calls(), 0);
997        Ok(())
998    }
999
1000    #[tokio::test]
1001    async fn granted_schedule_create_and_list_return_scoped_engine()
1002    -> Result<(), Box<dyn std::error::Error>> {
1003        let guard = guard_with_schedule_ownership(StaticScheduleNamespaces::default());
1004        let create = ProtoCreateScheduleRequest {
1005            namespace: String::from("tenant-a"),
1006            config: None,
1007        };
1008        let list = ProtoListSchedulesRequest {
1009            namespace: String::from("tenant-a"),
1010        };
1011
1012        let scoped_create = guard
1013            .scope(&caller(), &NamespaceOperation::create_schedule(&create))
1014            .await?;
1015        let scoped_list = guard
1016            .scope(&caller(), &NamespaceOperation::list_schedules(&list))
1017            .await?;
1018
1019        assert_eq!(scoped_create.namespace(), "tenant-a");
1020        assert_eq!(scoped_list.namespace(), "tenant-a");
1021        Ok(())
1022    }
1023
1024    #[tokio::test]
1025    async fn authorized_start_returns_scoped_engine() -> Result<(), Box<dyn std::error::Error>> {
1026        let guard = guard_with_ownership(StaticWorkflowNamespaces::default());
1027        let request = ProtoStartWorkflowRequest {
1028            namespace: String::from("tenant-a"),
1029            workflow_type: String::from("checkout"),
1030            input: None,
1031            routing_key: None,
1032            task_queue: None,
1033        };
1034
1035        let scoped = guard
1036            .scope(&caller(), &NamespaceOperation::start(&request))
1037            .await?;
1038
1039        assert_eq!(scoped.namespace(), "tenant-a");
1040        Ok(())
1041    }
1042
1043    #[tokio::test]
1044    async fn single_tenant_mode_authorizes_configured_namespace()
1045    -> Result<(), Box<dyn std::error::Error>> {
1046        let resolver = NamespaceResolver::authorization_only(
1047            NamespaceMode::SingleTenant {
1048                namespace: String::from("tenant-a"),
1049            },
1050            StaticWorkflowNamespaces::default(),
1051            StaticScheduleNamespaces::default(),
1052        );
1053        let guard = NamespaceGuard::new(resolver);
1054        let request = ProtoRegisterWorker {
1055            namespaces: vec![String::from("tenant-a")],
1056            activity_types: Vec::new(),
1057            task_queue: String::new(),
1058            node: String::new(),
1059            activities: Vec::new(),
1060            identity: String::new(),
1061            instance: None,
1062        };
1063
1064        let scoped = guard
1065            .scope(
1066                &CallerIdentity::new("single-tenant", Vec::<String>::new()),
1067                &NamespaceOperation::register_worker(&request),
1068            )
1069            .await?;
1070
1071        assert_eq!(scoped.namespace(), "tenant-a");
1072        Ok(())
1073    }
1074}