1use std::collections::{BTreeSet, HashMap};
10use std::sync::{Arc, RwLock};
11
12use aion::Engine;
13use aion_core::{ScheduleId, SearchAttributeValue, WorkflowId, search_attributes_from_events};
14use aion_proto::WireError;
15use async_trait::async_trait;
16
17use crate::config::{NamespaceConfig, NamespaceMode};
18use crate::error::ServerError;
19
20use super::schedule_source::{HistoryScheduleNamespaceSource, ScheduleNamespaceSource};
21
22pub const NAMESPACE_ATTRIBUTE: &str = aion_core::NAMESPACE_ATTRIBUTE;
26
27pub const TASK_QUEUE_ATTRIBUTE: &str = aion_core::START_TIME_TASK_QUEUE_ATTRIBUTE;
38
39pub const DISPLAY_NAME_ATTRIBUTE: &str = aion_core::DISPLAY_NAME_ATTRIBUTE;
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub(crate) enum GrantSource {
55 NamespacesHeader,
58 TokenClaim,
60 Operator,
66}
67
68impl GrantSource {
69 pub(crate) const fn label(self) -> &'static str {
71 match self {
72 Self::NamespacesHeader => "header",
73 Self::TokenClaim => "token_claim",
74 Self::Operator => "operator",
75 }
76 }
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct CallerIdentity {
82 subject: String,
83 namespaces: BTreeSet<String>,
84 denial_reason: Option<String>,
85 grant_source: GrantSource,
86 deploy: bool,
89 assistant_sessions: bool,
94 all_namespaces: bool,
99}
100
101impl CallerIdentity {
102 #[must_use]
105 pub fn new(subject: impl Into<String>, namespaces: impl IntoIterator<Item = String>) -> Self {
106 Self {
107 subject: subject.into(),
108 namespaces: namespaces.into_iter().collect(),
109 denial_reason: None,
110 grant_source: GrantSource::NamespacesHeader,
111 deploy: false,
112 assistant_sessions: false,
113 all_namespaces: false,
114 }
115 }
116
117 #[must_use]
121 pub fn from_token_claims(
122 subject: impl Into<String>,
123 namespaces: impl IntoIterator<Item = String>,
124 ) -> Self {
125 Self {
126 subject: subject.into(),
127 namespaces: namespaces.into_iter().collect(),
128 denial_reason: None,
129 grant_source: GrantSource::TokenClaim,
130 deploy: false,
131 assistant_sessions: false,
132 all_namespaces: false,
133 }
134 }
135
136 #[must_use]
147 pub fn operator(subject: impl Into<String>) -> Self {
148 Self {
149 subject: subject.into(),
150 namespaces: BTreeSet::new(),
151 denial_reason: None,
152 grant_source: GrantSource::Operator,
153 deploy: true,
154 assistant_sessions: true,
155 all_namespaces: true,
156 }
157 }
158
159 #[must_use]
166 pub fn with_deploy(mut self, deploy: bool) -> Self {
167 self.deploy = deploy;
168 self
169 }
170
171 #[must_use]
173 pub const fn deploy_granted(&self) -> bool {
174 self.deploy
175 }
176
177 #[must_use]
184 pub fn with_assistant_sessions(mut self, assistant_sessions: bool) -> Self {
185 self.assistant_sessions = assistant_sessions;
186 self
187 }
188
189 #[must_use]
192 pub const fn assistant_sessions_granted(&self) -> bool {
193 self.assistant_sessions
194 }
195
196 #[must_use]
198 pub fn denied(subject: impl Into<String>, reason: impl Into<String>) -> Self {
199 Self {
200 subject: subject.into(),
201 namespaces: BTreeSet::new(),
202 denial_reason: Some(reason.into()),
203 grant_source: GrantSource::NamespacesHeader,
204 deploy: false,
205 assistant_sessions: false,
206 all_namespaces: false,
207 }
208 }
209
210 #[must_use]
212 pub fn subject(&self) -> &str {
213 &self.subject
214 }
215
216 #[must_use]
221 pub fn namespaces(&self) -> Vec<String> {
222 self.namespaces.iter().cloned().collect()
223 }
224
225 #[must_use]
230 pub const fn all_namespaces(&self) -> bool {
231 self.all_namespaces
232 }
233
234 pub(crate) fn can_access(&self, namespace: &str) -> bool {
243 self.all_namespaces || self.namespaces.contains(namespace)
244 }
245
246 pub(crate) fn denial_reason(&self) -> Option<&str> {
247 self.denial_reason.as_deref()
248 }
249
250 pub(crate) const fn grant_source(&self) -> GrantSource {
253 self.grant_source
254 }
255}
256
257#[derive(Clone)]
259pub struct ScopedEngine {
260 namespace: String,
261 engine: Option<Arc<Engine>>,
262}
263
264impl ScopedEngine {
265 #[must_use]
267 pub fn namespace(&self) -> &str {
268 &self.namespace
269 }
270
271 pub fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
278 self.engine.as_ref().ok_or_else(|| ServerError::Config {
279 message: "namespace resolver has no engine handle".to_owned(),
280 })
281 }
282}
283
284#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct WorkflowAttribution {
292 pub namespace: String,
294 pub workflow_type: Option<String>,
297}
298
299#[async_trait]
305pub trait WorkflowNamespaceSource: Send + Sync {
306 async fn workflow_attribution(
313 &self,
314 workflow_id: &WorkflowId,
315 ) -> Result<Option<WorkflowAttribution>, ServerError>;
316}
317
318struct HistoryNamespaceSource {
322 engine: Arc<Engine>,
323}
324
325#[async_trait]
326impl WorkflowNamespaceSource for HistoryNamespaceSource {
327 async fn workflow_attribution(
328 &self,
329 workflow_id: &WorkflowId,
330 ) -> Result<Option<WorkflowAttribution>, ServerError> {
331 let history = self
332 .engine
333 .store()
334 .read_history(workflow_id)
335 .await
336 .map_err(ServerError::from)?;
337 let namespace = match search_attributes_from_events(&history).remove(NAMESPACE_ATTRIBUTE) {
338 Some(SearchAttributeValue::String(namespace)) => namespace,
339 Some(other) => {
340 return Err(ServerError::Config {
341 message: format!(
342 "workflow {workflow_id} recorded a non-string {NAMESPACE_ATTRIBUTE} search attribute: {other:?}"
343 ),
344 });
345 }
346 None => return Ok(None),
347 };
348 let workflow_type = history.iter().rev().find_map(|event| match event {
351 aion_core::Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
352 _ => None,
353 });
354 Ok(Some(WorkflowAttribution {
355 namespace,
356 workflow_type,
357 }))
358 }
359}
360
361#[derive(Clone, Default)]
364pub struct StaticWorkflowNamespaces {
365 inner: Arc<RwLock<HashMap<WorkflowId, WorkflowAttribution>>>,
366}
367
368impl StaticWorkflowNamespaces {
369 pub fn record(&self, workflow_id: WorkflowId, namespace: &str) -> Result<(), ServerError> {
377 self.insert(
378 workflow_id,
379 WorkflowAttribution {
380 namespace: namespace.to_owned(),
381 workflow_type: None,
382 },
383 )
384 }
385
386 pub fn record_with_type(
393 &self,
394 workflow_id: WorkflowId,
395 namespace: &str,
396 workflow_type: &str,
397 ) -> Result<(), ServerError> {
398 self.insert(
399 workflow_id,
400 WorkflowAttribution {
401 namespace: namespace.to_owned(),
402 workflow_type: Some(workflow_type.to_owned()),
403 },
404 )
405 }
406
407 fn insert(
408 &self,
409 workflow_id: WorkflowId,
410 attribution: WorkflowAttribution,
411 ) -> Result<(), ServerError> {
412 let mut ownership = self
413 .inner
414 .write()
415 .map_err(|_| ServerError::lock_poisoned("namespace workflow ownership"))?;
416 ownership.insert(workflow_id, attribution);
417 Ok(())
418 }
419}
420
421#[async_trait]
422impl WorkflowNamespaceSource for StaticWorkflowNamespaces {
423 async fn workflow_attribution(
424 &self,
425 workflow_id: &WorkflowId,
426 ) -> Result<Option<WorkflowAttribution>, ServerError> {
427 let ownership = self
428 .inner
429 .read()
430 .map_err(|_| ServerError::lock_poisoned("namespace workflow ownership"))?;
431 Ok(ownership.get(workflow_id).cloned())
432 }
433}
434
435#[derive(Clone)]
437pub struct NamespaceResolver {
438 mode: NamespaceMode,
439 engine: Option<Arc<Engine>>,
440 ownership: Arc<dyn WorkflowNamespaceSource>,
441 schedule_ownership: Arc<dyn ScheduleNamespaceSource>,
442}
443
444impl NamespaceResolver {
445 #[must_use]
448 pub fn from_config(config: NamespaceConfig, engine: Arc<Engine>) -> Self {
449 Self {
450 mode: config.mode,
451 ownership: Arc::new(HistoryNamespaceSource {
452 engine: Arc::clone(&engine),
453 }),
454 schedule_ownership: Arc::new(HistoryScheduleNamespaceSource::new(Arc::clone(&engine))),
455 engine: Some(engine),
456 }
457 }
458
459 #[must_use]
461 pub fn from_parts(
462 mode: NamespaceMode,
463 engine: Option<Arc<Engine>>,
464 ownership: Arc<dyn WorkflowNamespaceSource>,
465 schedule_ownership: Arc<dyn ScheduleNamespaceSource>,
466 ) -> Self {
467 Self {
468 mode,
469 engine,
470 ownership,
471 schedule_ownership,
472 }
473 }
474
475 #[must_use]
480 pub fn authorization_only(
481 mode: NamespaceMode,
482 ownership: impl WorkflowNamespaceSource + 'static,
483 schedule_ownership: impl ScheduleNamespaceSource + 'static,
484 ) -> Self {
485 Self::from_parts(
486 mode,
487 None,
488 Arc::new(ownership),
489 Arc::new(schedule_ownership),
490 )
491 }
492
493 #[must_use]
495 pub const fn mode(&self) -> &NamespaceMode {
496 &self.mode
497 }
498
499 pub(crate) fn engine(&self) -> Result<&Arc<Engine>, ServerError> {
506 self.engine.as_ref().ok_or_else(|| ServerError::Config {
507 message: "namespace resolver has no engine handle".to_owned(),
508 })
509 }
510
511 pub fn shutdown_engine(&self) -> Result<(), ServerError> {
518 self.engine
519 .as_ref()
520 .ok_or_else(|| ServerError::Config {
521 message: "namespace resolver has no engine handle".to_owned(),
522 })?
523 .shutdown()
524 .map_err(ServerError::from)
525 }
526
527 pub(super) fn resolve(
535 &self,
536 caller: &CallerIdentity,
537 requested_namespace: &str,
538 ) -> Result<ScopedEngine, ServerError> {
539 if requested_namespace.is_empty() {
540 return Err(ServerError::namespace_denied(
541 "requested namespace must not be empty",
542 ));
543 }
544
545 if let Some(reason) = caller.denial_reason() {
546 return Err(ServerError::namespace_denied(reason));
547 }
548
549 match &self.mode {
550 NamespaceMode::SingleTenant { namespace } if namespace == requested_namespace => {
551 Ok(self.scoped(requested_namespace))
552 }
553 NamespaceMode::SharedEngine if caller.can_access(requested_namespace) => {
554 Ok(self.scoped(requested_namespace))
555 }
556 NamespaceMode::SingleTenant { .. } | NamespaceMode::SharedEngine => {
557 Err(namespace_denied(caller, requested_namespace))
558 }
559 }
560 }
561
562 pub async fn verify_workflow_ownership(
580 &self,
581 namespace: &str,
582 workflow_id: &WorkflowId,
583 ) -> Result<(), ServerError> {
584 match self.workflow_attribution(namespace, workflow_id).await? {
585 Some(_) => Ok(()),
586 None => Err(ServerError::Wire {
587 wire: WireError::not_found(format!("workflow not found in namespace {namespace}")),
588 }),
589 }
590 }
591
592 pub async fn workflow_attribution(
608 &self,
609 namespace: &str,
610 workflow_id: &WorkflowId,
611 ) -> Result<Option<WorkflowAttribution>, ServerError> {
612 Ok(self
613 .ownership
614 .workflow_attribution(workflow_id)
615 .await?
616 .filter(|attribution| attribution.namespace == namespace))
617 }
618
619 pub async fn recorded_workflow_attribution(
644 &self,
645 workflow_id: &WorkflowId,
646 ) -> Result<Option<WorkflowAttribution>, ServerError> {
647 self.ownership.workflow_attribution(workflow_id).await
648 }
649
650 pub async fn verify_schedule_ownership(
668 &self,
669 namespace: &str,
670 schedule_id: &ScheduleId,
671 ) -> Result<(), ServerError> {
672 match self
673 .schedule_ownership
674 .schedule_namespace(schedule_id)
675 .await?
676 {
677 Some(owner) if owner == namespace => Ok(()),
678 Some(_) | None => Err(ServerError::Wire {
681 wire: WireError::not_found(format!("schedule not found in namespace {namespace}")),
682 }),
683 }
684 }
685
686 fn scoped(&self, namespace: &str) -> ScopedEngine {
687 ScopedEngine {
688 namespace: namespace.to_owned(),
689 engine: self.engine.clone(),
690 }
691 }
692}
693
694fn namespace_denied(caller: &CallerIdentity, requested_namespace: &str) -> ServerError {
695 let hint = match caller.grant_source {
696 GrantSource::NamespacesHeader => format!(
697 "add {requested_namespace} to x-aion-namespaces for subject `{}` or request a namespace listed in that header",
698 caller.subject()
699 ),
700 GrantSource::TokenClaim => format!(
701 "grant {requested_namespace} in the namespace claim of the token minted for subject `{}` or request a namespace the token grants",
702 caller.subject()
703 ),
704 GrantSource::Operator => format!(
708 "subject `{}` is the operator and already holds every namespace",
709 caller.subject()
710 ),
711 };
712 ServerError::namespace_denied(format!(
713 "subject not authorized for namespace {requested_namespace}; {hint}"
714 ))
715}
716
717#[cfg(test)]
718mod tests {
719 use super::{
720 CallerIdentity, NamespaceResolver, StaticWorkflowNamespaces, WorkflowNamespaceSource,
721 };
722 use crate::config::NamespaceMode;
723 use crate::namespace::StaticScheduleNamespaces;
724 use aion_core::{ScheduleId, WorkflowId};
725
726 fn resolver(mode: NamespaceMode) -> NamespaceResolver {
727 NamespaceResolver::authorization_only(
728 mode,
729 StaticWorkflowNamespaces::default(),
730 StaticScheduleNamespaces::default(),
731 )
732 }
733
734 #[test]
735 fn shared_engine_authorizes_explicit_caller_grant() -> Result<(), Box<dyn std::error::Error>> {
736 let resolver = resolver(NamespaceMode::SharedEngine);
737 let caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
738
739 let scoped = resolver.resolve(&caller, "tenant-a")?;
740
741 assert_eq!(scoped.namespace(), "tenant-a");
742 Ok(())
743 }
744
745 #[test]
750 fn operator_is_authorized_for_any_namespace() -> Result<(), Box<dyn std::error::Error>> {
751 let resolver = resolver(NamespaceMode::SharedEngine);
752 let operator = CallerIdentity::operator("operator");
753
754 assert!(operator.all_namespaces());
755 assert!(operator.deploy_granted());
756 assert!(operator.namespaces().is_empty());
757
758 assert_eq!(
759 resolver.resolve(&operator, "tenant-a")?.namespace(),
760 "tenant-a"
761 );
762 assert_eq!(
763 resolver.resolve(&operator, "tenant-z")?.namespace(),
764 "tenant-z"
765 );
766 Ok(())
767 }
768
769 #[test]
770 fn shared_engine_denies_missing_caller_grant() {
771 let resolver = resolver(NamespaceMode::SharedEngine);
772 let caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
773
774 let denied = resolver.resolve(&caller, "tenant-b");
775
776 assert!(denied.is_err());
777 }
778
779 #[test]
780 fn single_tenant_authorizes_only_configured_namespace() -> Result<(), Box<dyn std::error::Error>>
781 {
782 let resolver = resolver(NamespaceMode::SingleTenant {
783 namespace: String::from("tenant-a"),
784 });
785 let caller = CallerIdentity::new("alice", [String::from("tenant-b")]);
786
787 let scoped = resolver.resolve(&caller, "tenant-a")?;
788 let denied = resolver.resolve(&caller, "tenant-b");
789
790 assert_eq!(scoped.namespace(), "tenant-a");
791 assert!(denied.is_err());
792 Ok(())
793 }
794
795 #[test]
800 fn denial_hint_names_the_grant_source() -> Result<(), Box<dyn std::error::Error>> {
801 let resolver = resolver(NamespaceMode::SharedEngine);
802
803 let header_caller = CallerIdentity::new("alice", [String::from("tenant-a")]);
804 let header_denial = resolver
805 .resolve(&header_caller, "tenant-b")
806 .err()
807 .map(|error| error.to_wire_error())
808 .ok_or("expected header-sourced caller to be denied")?;
809 assert!(
810 header_denial.message.contains("x-aion-namespaces"),
811 "header-path denial must hint the dev header: {}",
812 header_denial.message
813 );
814 assert!(
815 !header_denial.message.contains("namespace claim"),
816 "header-path denial must not hint the token claim: {}",
817 header_denial.message
818 );
819
820 let token_caller = CallerIdentity::from_token_claims("alice", [String::from("tenant-a")]);
821 let token_denial = resolver
822 .resolve(&token_caller, "tenant-b")
823 .err()
824 .map(|error| error.to_wire_error())
825 .ok_or("expected token-sourced caller to be denied")?;
826 assert!(
827 token_denial.message.contains("namespace claim"),
828 "JWT-path denial must hint the token's namespace claim: {}",
829 token_denial.message
830 );
831 assert!(
832 !token_denial.message.contains("x-aion-namespaces"),
833 "JWT-path denial must not hint the dev header: {}",
834 token_denial.message
835 );
836 Ok(())
837 }
838
839 #[test]
840 fn empty_namespace_is_denied_before_scoping() {
841 let resolver = resolver(NamespaceMode::SharedEngine);
842 let caller = CallerIdentity::new("alice", [String::new()]);
843
844 let denied = resolver.resolve(&caller, "");
845
846 assert!(denied.is_err());
847 }
848
849 #[tokio::test]
850 async fn ownership_misses_are_indistinguishable_not_found()
851 -> Result<(), Box<dyn std::error::Error>> {
852 let ownership = StaticWorkflowNamespaces::default();
853 let owned = WorkflowId::new(uuid::Uuid::from_u128(1));
854 let unknown = WorkflowId::new(uuid::Uuid::from_u128(2));
855 ownership.record(owned.clone(), "tenant-a")?;
856 let resolver = NamespaceResolver::authorization_only(
857 NamespaceMode::SharedEngine,
858 ownership,
859 StaticScheduleNamespaces::default(),
860 );
861
862 resolver
863 .verify_workflow_ownership("tenant-a", &owned)
864 .await?;
865
866 let foreign = resolver
870 .verify_workflow_ownership("tenant-b", &owned)
871 .await
872 .err()
873 .map(|error| error.to_wire_error())
874 .ok_or("expected foreign-owned workflow to be rejected")?;
875 let absent = resolver
876 .verify_workflow_ownership("tenant-b", &unknown)
877 .await
878 .err()
879 .map(|error| error.to_wire_error())
880 .ok_or("expected unknown workflow to be rejected")?;
881
882 assert_eq!(foreign.code, aion_proto::WireErrorCode::NotFound);
883 assert_eq!(foreign, absent);
884 assert_eq!(foreign.message, "workflow not found in namespace tenant-b");
885
886 let absent_in_granted = resolver
887 .verify_workflow_ownership("tenant-a", &unknown)
888 .await
889 .err()
890 .map(|error| error.to_wire_error())
891 .ok_or("expected unknown workflow to be rejected in granted namespace")?;
892 assert_eq!(absent_in_granted.code, aion_proto::WireErrorCode::NotFound);
893 assert_eq!(
894 absent_in_granted.message,
895 "workflow not found in namespace tenant-a"
896 );
897 Ok(())
898 }
899
900 #[tokio::test]
901 async fn schedule_ownership_misses_are_indistinguishable_not_found()
902 -> Result<(), Box<dyn std::error::Error>> {
903 let schedule_ownership = StaticScheduleNamespaces::default();
904 let owned = ScheduleId::new(uuid::Uuid::from_u128(1));
905 let unknown = ScheduleId::new(uuid::Uuid::from_u128(2));
906 schedule_ownership.record(owned.clone(), "tenant-a")?;
907 let resolver = NamespaceResolver::authorization_only(
908 NamespaceMode::SharedEngine,
909 StaticWorkflowNamespaces::default(),
910 schedule_ownership,
911 );
912
913 resolver
914 .verify_schedule_ownership("tenant-a", &owned)
915 .await?;
916
917 let foreign = resolver
921 .verify_schedule_ownership("tenant-b", &owned)
922 .await
923 .err()
924 .map(|error| error.to_wire_error())
925 .ok_or("expected foreign-owned schedule to be rejected")?;
926 let absent = resolver
927 .verify_schedule_ownership("tenant-b", &unknown)
928 .await
929 .err()
930 .map(|error| error.to_wire_error())
931 .ok_or("expected unknown schedule to be rejected")?;
932
933 assert_eq!(foreign.code, aion_proto::WireErrorCode::NotFound);
934 assert_eq!(foreign, absent);
935 assert_eq!(foreign.message, "schedule not found in namespace tenant-b");
936
937 let absent_in_granted = resolver
938 .verify_schedule_ownership("tenant-a", &unknown)
939 .await
940 .err()
941 .map(|error| error.to_wire_error())
942 .ok_or("expected unknown schedule to be rejected in granted namespace")?;
943 assert_eq!(absent_in_granted.code, aion_proto::WireErrorCode::NotFound);
944 assert_eq!(
945 absent_in_granted.message,
946 "schedule not found in namespace tenant-a"
947 );
948 Ok(())
949 }
950
951 #[tokio::test]
952 async fn static_source_reports_recorded_namespace() -> Result<(), Box<dyn std::error::Error>> {
953 let ownership = StaticWorkflowNamespaces::default();
954 let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
955 ownership.record(workflow_id.clone(), "tenant-a")?;
956
957 assert_eq!(
958 ownership.workflow_attribution(&workflow_id).await?,
959 Some(super::WorkflowAttribution {
960 namespace: String::from("tenant-a"),
961 workflow_type: None,
962 })
963 );
964 Ok(())
965 }
966
967 #[tokio::test]
968 async fn static_source_reports_recorded_workflow_type() -> Result<(), Box<dyn std::error::Error>>
969 {
970 let ownership = StaticWorkflowNamespaces::default();
971 let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(4));
972 ownership.record_with_type(workflow_id.clone(), "tenant-a", "checkout")?;
973
974 assert_eq!(
975 ownership.workflow_attribution(&workflow_id).await?,
976 Some(super::WorkflowAttribution {
977 namespace: String::from("tenant-a"),
978 workflow_type: Some(String::from("checkout")),
979 })
980 );
981 Ok(())
982 }
983
984 #[tokio::test]
988 async fn scoped_attribution_hides_foreign_and_unknown_identically()
989 -> Result<(), Box<dyn std::error::Error>> {
990 let ownership = StaticWorkflowNamespaces::default();
991 let owned = WorkflowId::new(uuid::Uuid::from_u128(5));
992 let foreign = WorkflowId::new(uuid::Uuid::from_u128(6));
993 let unknown = WorkflowId::new(uuid::Uuid::from_u128(7));
994 ownership.record_with_type(owned.clone(), "tenant-a", "checkout")?;
995 ownership.record_with_type(foreign.clone(), "tenant-b", "checkout")?;
996 let resolver = NamespaceResolver::authorization_only(
997 NamespaceMode::SharedEngine,
998 ownership,
999 StaticScheduleNamespaces::default(),
1000 );
1001
1002 let visible = resolver
1003 .workflow_attribution("tenant-a", &owned)
1004 .await?
1005 .ok_or("owned workflow attribution must be visible")?;
1006 assert_eq!(visible.workflow_type.as_deref(), Some("checkout"));
1007 assert_eq!(
1008 resolver.workflow_attribution("tenant-a", &foreign).await?,
1009 None
1010 );
1011 assert_eq!(
1012 resolver.workflow_attribution("tenant-a", &unknown).await?,
1013 None
1014 );
1015 Ok(())
1016 }
1017}