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