1pub mod governance;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum EnterpriseMode {
8 Disabled,
9 Optional,
10 Required,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum RuntimeAuthMode {
16 #[default]
17 LocalSingleTenant,
18 HostedSingleTenant,
19 EnterpriseRequired,
20}
21
22impl RuntimeAuthMode {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::LocalSingleTenant => "local_single_tenant",
26 Self::HostedSingleTenant => "hosted_single_tenant",
27 Self::EnterpriseRequired => "enterprise_required",
28 }
29 }
30
31 pub fn parse(value: &str) -> Result<Self, ParseRuntimeAuthModeError> {
32 value.parse()
33 }
34}
35
36impl core::str::FromStr for RuntimeAuthMode {
37 type Err = ParseRuntimeAuthModeError;
38
39 fn from_str(value: &str) -> Result<Self, Self::Err> {
40 match value.trim().to_ascii_lowercase().as_str() {
41 ""
42 | "local"
43 | "local_single_tenant"
44 | "local-single-tenant"
45 | "single_tenant"
46 | "single-tenant" => Ok(Self::LocalSingleTenant),
47 "hosted" | "hosted_single_tenant" | "hosted-single-tenant" => {
48 Ok(Self::HostedSingleTenant)
49 }
50 "enterprise" | "enterprise_required" | "enterprise-required" | "required" => {
51 Ok(Self::EnterpriseRequired)
52 }
53 _ => Err(ParseRuntimeAuthModeError),
54 }
55 }
56}
57
58impl core::fmt::Display for RuntimeAuthMode {
59 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60 f.write_str(self.as_str())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct ParseRuntimeAuthModeError;
66
67impl core::fmt::Display for ParseRuntimeAuthModeError {
68 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
69 f.write_str("invalid runtime auth mode")
70 }
71}
72
73impl std::error::Error for ParseRuntimeAuthModeError {}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum EnterpriseBridgeState {
78 Absent,
79 Noop,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum EnterpriseCapability {
85 Status,
86 TenantContext,
87 NoopBridge,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum TenantSource {
93 #[default]
94 LocalImplicit,
95 Explicit,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RequestPrincipal {
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub actor_id: Option<String>,
102 #[serde(default)]
103 pub source: String,
104}
105
106impl RequestPrincipal {
107 pub fn anonymous() -> Self {
108 Self {
109 actor_id: None,
110 source: "anonymous".to_string(),
111 }
112 }
113
114 pub fn authenticated_user(actor_id: impl Into<String>, source: impl Into<String>) -> Self {
115 Self {
116 actor_id: Some(actor_id.into()),
117 source: source.into(),
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct AutomationPrincipal {
124 pub automation_id: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub owner_id: Option<String>,
127 #[serde(default)]
128 pub source: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum ExecutionPrincipal {
134 Request(RequestPrincipal),
135 Automation(AutomationPrincipal),
136 ServiceAccount {
137 service_account_id: String,
138 },
139 #[default]
140 Unknown,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct AuthorityChain {
145 pub initiated_by: RequestPrincipal,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub owned_by: Option<AutomationPrincipal>,
148 pub executed_as: ExecutionPrincipal,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub approved_by: Option<RequestPrincipal>,
151}
152
153impl AuthorityChain {
154 pub fn from_request(principal: RequestPrincipal) -> Self {
155 Self {
156 initiated_by: principal.clone(),
157 owned_by: None,
158 executed_as: ExecutionPrincipal::Request(principal),
159 approved_by: None,
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct HumanActor {
166 pub actor_id: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub provider: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub issuer: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub subject: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub email: Option<String>,
175}
176
177impl HumanActor {
178 pub fn tandem_user(actor_id: impl Into<String>) -> Self {
179 Self {
180 actor_id: actor_id.into(),
181 provider: Some("tandem".to_string()),
182 issuer: None,
183 subject: None,
184 email: None,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum ResourceKind {
192 Organization,
193 Workspace,
194 OrganizationUnit,
195 Department,
196 Group,
197 Project,
198 DataRoom,
199 SharedDrive,
200 DocumentCollection,
201 DataStore,
202 Dataset,
203 Document,
204 Repository,
205 Directory,
206 File,
207 Artifact,
208 MemorySpace,
209 KnowledgeSpace,
210 SecretProviderCredential,
211 Automation,
212 Run,
213 Approval,
214 AuditExport,
215 McpServer,
216 McpTool,
217 ConnectorInstance,
218 SourceBinding,
219 SourceObject,
220 IngestionJob,
221 ExternalIntegrationAccount,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct ResourcePathSegment {
226 pub kind: ResourceKind,
227 pub id: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub name: Option<String>,
230}
231
232impl ResourcePathSegment {
233 pub fn new(kind: ResourceKind, id: impl Into<String>) -> Self {
234 Self {
235 kind,
236 id: id.into(),
237 name: None,
238 }
239 }
240
241 pub fn named(kind: ResourceKind, id: impl Into<String>, name: impl Into<String>) -> Self {
242 Self {
243 kind,
244 id: id.into(),
245 name: Some(name.into()),
246 }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct ResourceRef {
252 pub organization_id: String,
253 pub workspace_id: String,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub project_id: Option<String>,
256 pub resource_kind: ResourceKind,
257 pub resource_id: String,
258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub parent_path: Vec<ResourcePathSegment>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub branch_id: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub path_prefix: Option<String>,
264}
265
266impl ResourceRef {
267 pub fn new(
268 organization_id: impl Into<String>,
269 workspace_id: impl Into<String>,
270 resource_kind: ResourceKind,
271 resource_id: impl Into<String>,
272 ) -> Self {
273 Self {
274 organization_id: organization_id.into(),
275 workspace_id: workspace_id.into(),
276 project_id: None,
277 resource_kind,
278 resource_id: resource_id.into(),
279 parent_path: Vec::new(),
280 branch_id: None,
281 path_prefix: None,
282 }
283 }
284
285 pub fn with_project_id(mut self, project_id: impl Into<String>) -> Self {
286 self.project_id = Some(project_id.into());
287 self
288 }
289
290 pub fn with_parent_path(mut self, parent_path: Vec<ResourcePathSegment>) -> Self {
291 self.parent_path = parent_path;
292 self
293 }
294
295 pub fn with_branch_id(mut self, branch_id: impl Into<String>) -> Self {
296 self.branch_id = Some(branch_id.into());
297 self
298 }
299
300 pub fn with_path_prefix(mut self, path_prefix: impl Into<String>) -> Self {
301 self.path_prefix = Some(path_prefix.into());
302 self
303 }
304
305 pub fn applies_to(&self, target: &ResourceRef) -> bool {
306 if self.organization_id != target.organization_id {
307 return false;
308 }
309 if self.workspace_id != "*" && self.workspace_id != target.workspace_id {
310 return false;
311 }
312
313 match self.resource_kind {
314 ResourceKind::Organization => self.resource_id == target.organization_id,
315 ResourceKind::Workspace | ResourceKind::Department => {
316 self.resource_id == target.workspace_id
317 || self.resource_id == "*"
318 || self.resource_id == target.resource_id
319 }
320 ResourceKind::Project => {
321 target.project_id.as_deref() == Some(self.resource_id.as_str())
322 || target.resource_id == self.resource_id
323 }
324 _ => self.matches_resource_or_path(target),
325 }
326 }
327
328 fn matches_resource_or_path(&self, target: &ResourceRef) -> bool {
329 if self.project_id.is_some() && self.project_id != target.project_id {
330 return false;
331 }
332 if self.resource_kind == target.resource_kind && self.resource_id == target.resource_id {
333 return self.path_prefix_applies_to(target);
334 }
335 self.path_prefix
336 .as_deref()
337 .zip(target.path_prefix.as_deref())
338 .map(|(prefix, target_prefix)| target_prefix.starts_with(prefix))
339 .unwrap_or(false)
340 }
341
342 fn path_prefix_applies_to(&self, target: &ResourceRef) -> bool {
343 match (self.path_prefix.as_deref(), target.path_prefix.as_deref()) {
344 (Some(prefix), Some(target_prefix)) => target_prefix.starts_with(prefix),
345 (Some(_), None) => false,
346 (None, _) => true,
347 }
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct ResourceScope {
353 pub root: ResourceRef,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub allowed_resources: Vec<ResourceRef>,
356 #[serde(default, skip_serializing_if = "Vec::is_empty")]
357 pub denied_resources: Vec<ResourceRef>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub max_depth: Option<u32>,
360}
361
362impl ResourceScope {
363 pub fn root(root: ResourceRef) -> Self {
364 Self {
365 root,
366 allowed_resources: Vec::new(),
367 denied_resources: Vec::new(),
368 max_depth: None,
369 }
370 }
371
372 pub fn explicitly_denies(&self, resource: &ResourceRef) -> bool {
373 self.denied_resources
374 .iter()
375 .any(|denied| denied.applies_to(resource))
376 }
377
378 pub fn contains(&self, resource: &ResourceRef) -> bool {
379 !self.explicitly_denies(resource)
380 && (self.root.applies_to(resource)
381 || self
382 .allowed_resources
383 .iter()
384 .any(|allowed| allowed.applies_to(resource)))
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(rename_all = "snake_case")]
390pub enum AccessPermission {
391 View,
392 Read,
393 Edit,
394 Execute,
395 Delegate,
396 Admin,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum DataClass {
402 Public,
403 Internal,
404 Confidential,
405 Restricted,
406 Executive,
407 Credential,
408 Regulated,
409 CustomerData,
410 SourceCode,
411 FinancialRecord,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum PrincipalKind {
417 HumanUser,
418 OrganizationUnit,
419 Group,
420 Department,
421 AgentWorker,
422 Automation,
423 ServiceAccount,
424 ExternalDelegate,
425 SupportOperator,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct PrincipalRef {
430 pub kind: PrincipalKind,
431 pub id: String,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub tenant_actor_id: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub issuer: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub subject: Option<String>,
438}
439
440impl PrincipalRef {
441 pub fn new(kind: PrincipalKind, id: impl Into<String>) -> Self {
442 Self {
443 kind,
444 id: id.into(),
445 tenant_actor_id: None,
446 issuer: None,
447 subject: None,
448 }
449 }
450
451 pub fn human_user(id: impl Into<String>) -> Self {
452 Self::new(PrincipalKind::HumanUser, id)
453 }
454
455 pub fn agent_worker(id: impl Into<String>) -> Self {
456 Self::new(PrincipalKind::AgentWorker, id)
457 }
458
459 pub fn organization_unit(id: impl Into<String>) -> Self {
460 Self::new(PrincipalKind::OrganizationUnit, id)
461 }
462
463 pub fn with_tenant_actor_id(mut self, tenant_actor_id: impl Into<String>) -> Self {
464 self.tenant_actor_id = Some(tenant_actor_id.into());
465 self
466 }
467
468 pub fn with_issuer_subject(
469 mut self,
470 issuer: impl Into<String>,
471 subject: impl Into<String>,
472 ) -> Self {
473 self.issuer = Some(issuer.into());
474 self.subject = Some(subject.into());
475 self
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum GrantSource {
482 Direct,
483 OrganizationUnitMembership,
484 GroupMembership,
485 DepartmentMembership,
486 Inherited,
487 ExecutiveGlobal,
488 Delegation,
489 BreakGlass,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
493#[serde(rename_all = "snake_case")]
494pub enum AccessEffect {
495 #[default]
496 Allow,
497 Deny,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct ScopedGrant {
502 pub grant_id: String,
503 pub principal: PrincipalRef,
504 pub resource: ResourceRef,
505 #[serde(default)]
506 pub effect: AccessEffect,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
508 pub permissions: Vec<AccessPermission>,
509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub data_classes: Vec<DataClass>,
511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
512 pub tool_patterns: Vec<String>,
513 pub grant_source: GrantSource,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub source_principal: Option<PrincipalRef>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub expires_at_ms: Option<u64>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub delegation_id: Option<String>,
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
523#[serde(rename_all = "snake_case")]
524pub enum OrganizationUnitKind {
525 Department,
526 Team,
527 RoleDomain,
528 ContractorGroup,
529 ExecutiveGroup,
530 ClinicalGroup,
531 OperationalGroup,
532 Custom,
533 #[default]
534 Unspecified,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
538#[serde(rename_all = "snake_case")]
539pub enum OrganizationUnitState {
540 #[default]
541 Active,
542 Disabled,
543}
544
545impl OrganizationUnitState {
546 pub fn is_active(self) -> bool {
547 matches!(self, Self::Active)
548 }
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct OrganizationUnit {
553 pub unit_id: String,
554 pub tenant_context: TenantContext,
555 #[serde(default = "default_taxonomy_id")]
556 pub taxonomy_id: String,
557 pub display_name: String,
558 #[serde(default)]
559 pub kind: OrganizationUnitKind,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub parent_unit_id: Option<String>,
562 #[serde(default)]
563 pub state: OrganizationUnitState,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub description: Option<String>,
566 #[serde(default, skip_serializing_if = "Vec::is_empty")]
567 pub labels: Vec<String>,
568 pub created_by: PrincipalRef,
569 pub created_at_ms: u64,
570 pub updated_at_ms: u64,
571}
572
573impl OrganizationUnit {
574 pub fn active(
575 unit_id: impl Into<String>,
576 tenant_context: TenantContext,
577 display_name: impl Into<String>,
578 kind: OrganizationUnitKind,
579 created_by: PrincipalRef,
580 now_ms: u64,
581 ) -> Self {
582 Self {
583 unit_id: unit_id.into(),
584 tenant_context,
585 taxonomy_id: default_taxonomy_id(),
586 display_name: display_name.into(),
587 kind,
588 parent_unit_id: None,
589 state: OrganizationUnitState::Active,
590 description: None,
591 labels: Vec::new(),
592 created_by,
593 created_at_ms: now_ms,
594 updated_at_ms: now_ms,
595 }
596 }
597
598 pub fn with_parent_unit_id(mut self, parent_unit_id: impl Into<String>) -> Self {
599 self.parent_unit_id = Some(parent_unit_id.into());
600 self
601 }
602
603 pub fn with_taxonomy_id(mut self, taxonomy_id: impl Into<String>) -> Self {
604 self.taxonomy_id = taxonomy_id.into();
605 self
606 }
607
608 pub fn with_state(mut self, state: OrganizationUnitState, updated_at_ms: u64) -> Self {
609 self.state = state;
610 self.updated_at_ms = updated_at_ms;
611 self
612 }
613
614 pub fn principal_ref(&self) -> PrincipalRef {
615 PrincipalRef::organization_unit(format!("{}/{}", self.taxonomy_id, self.unit_id))
616 }
617
618 pub fn resource_ref(&self) -> ResourceRef {
619 ResourceRef::new(
620 self.tenant_context.org_id.clone(),
621 self.tenant_context.workspace_id.clone(),
622 ResourceKind::OrganizationUnit,
623 format!("{}/{}", self.taxonomy_id, self.unit_id),
624 )
625 }
626}
627
628fn default_taxonomy_id() -> String {
629 "organization_unit".to_string()
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
633#[serde(rename_all = "snake_case")]
634pub enum OrganizationUnitMembershipSource {
635 #[default]
636 Direct,
637 HostedControlPlane,
638 Scim,
639 GoogleWorkspace,
640 Okta,
641 ManualImport,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645pub struct OrganizationUnitMembership {
646 pub membership_id: String,
647 pub tenant_context: TenantContext,
648 pub unit: PrincipalRef,
649 pub member: PrincipalRef,
650 #[serde(default)]
651 pub source: OrganizationUnitMembershipSource,
652 #[serde(default)]
653 pub state: OrganizationUnitState,
654 pub created_at_ms: u64,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub expires_at_ms: Option<u64>,
657}
658
659impl OrganizationUnitMembership {
660 pub fn active(
661 membership_id: impl Into<String>,
662 tenant_context: TenantContext,
663 unit: PrincipalRef,
664 member: PrincipalRef,
665 source: OrganizationUnitMembershipSource,
666 created_at_ms: u64,
667 ) -> Self {
668 Self {
669 membership_id: membership_id.into(),
670 tenant_context,
671 unit,
672 member,
673 source,
674 state: OrganizationUnitState::Active,
675 created_at_ms,
676 expires_at_ms: None,
677 }
678 }
679
680 pub fn with_expires_at_ms(mut self, expires_at_ms: u64) -> Self {
681 self.expires_at_ms = Some(expires_at_ms);
682 self
683 }
684
685 pub fn is_active_at(&self, now_ms: u64) -> bool {
686 self.state.is_active()
687 && self
688 .expires_at_ms
689 .map(|expires_at_ms| expires_at_ms > now_ms)
690 .unwrap_or(true)
691 }
692}
693
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695pub struct OrganizationUnitAccessGrant {
696 pub grant_id: String,
697 pub tenant_context: TenantContext,
698 pub unit: PrincipalRef,
699 pub resource: ResourceRef,
700 #[serde(default)]
701 pub effect: AccessEffect,
702 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub permissions: Vec<AccessPermission>,
704 #[serde(default, skip_serializing_if = "Vec::is_empty")]
705 pub data_classes: Vec<DataClass>,
706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub tool_patterns: Vec<String>,
708 #[serde(default)]
709 pub state: OrganizationUnitState,
710 pub created_at_ms: u64,
711 pub updated_at_ms: u64,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub expires_at_ms: Option<u64>,
714}
715
716impl OrganizationUnitAccessGrant {
717 pub fn active(
718 grant_id: impl Into<String>,
719 tenant_context: TenantContext,
720 unit: PrincipalRef,
721 resource: ResourceRef,
722 created_at_ms: u64,
723 ) -> Self {
724 Self {
725 grant_id: grant_id.into(),
726 tenant_context,
727 unit,
728 resource,
729 effect: AccessEffect::Allow,
730 permissions: Vec::new(),
731 data_classes: Vec::new(),
732 tool_patterns: Vec::new(),
733 state: OrganizationUnitState::Active,
734 created_at_ms,
735 updated_at_ms: created_at_ms,
736 expires_at_ms: None,
737 }
738 }
739
740 pub fn with_effect(mut self, effect: AccessEffect) -> Self {
741 self.effect = effect;
742 self
743 }
744
745 pub fn with_permissions(mut self, permissions: Vec<AccessPermission>) -> Self {
746 self.permissions = permissions;
747 self
748 }
749
750 pub fn with_data_classes(mut self, data_classes: Vec<DataClass>) -> Self {
751 self.data_classes = data_classes;
752 self
753 }
754
755 pub fn with_tool_patterns(mut self, tool_patterns: Vec<String>) -> Self {
756 self.tool_patterns = tool_patterns;
757 self
758 }
759
760 pub fn with_expires_at_ms(mut self, expires_at_ms: u64) -> Self {
761 self.expires_at_ms = Some(expires_at_ms);
762 self
763 }
764
765 pub fn is_active_at(&self, now_ms: u64) -> bool {
766 self.state.is_active()
767 && self
768 .expires_at_ms
769 .map(|expires_at_ms| expires_at_ms > now_ms)
770 .unwrap_or(true)
771 }
772
773 pub fn to_scoped_grant_for_membership(
774 &self,
775 membership: &OrganizationUnitMembership,
776 now_ms: u64,
777 ) -> Option<ScopedGrant> {
778 if self.tenant_context.org_id != membership.tenant_context.org_id
779 || self.tenant_context.workspace_id != membership.tenant_context.workspace_id
780 || self.tenant_context.deployment_id != membership.tenant_context.deployment_id
781 || self.unit != membership.unit
782 || !self.is_active_at(now_ms)
783 || !membership.is_active_at(now_ms)
784 {
785 return None;
786 }
787
788 let mut grant = ScopedGrant::new(
789 format!("{}::{}", membership.membership_id, self.grant_id),
790 membership.member.clone(),
791 self.resource.clone(),
792 GrantSource::OrganizationUnitMembership,
793 )
794 .with_effect(self.effect)
795 .with_permissions(self.permissions.clone())
796 .with_data_classes(self.data_classes.clone())
797 .with_tool_patterns(self.tool_patterns.clone())
798 .with_source_principal(self.unit.clone());
799
800 grant.expires_at_ms = match (membership.expires_at_ms, self.expires_at_ms) {
801 (Some(left), Some(right)) => Some(left.min(right)),
802 (Some(value), None) | (None, Some(value)) => Some(value),
803 (None, None) => None,
804 };
805 Some(grant)
806 }
807}
808
809impl ScopedGrant {
810 pub fn new(
811 grant_id: impl Into<String>,
812 principal: PrincipalRef,
813 resource: ResourceRef,
814 grant_source: GrantSource,
815 ) -> Self {
816 Self {
817 grant_id: grant_id.into(),
818 principal,
819 resource,
820 effect: AccessEffect::Allow,
821 permissions: Vec::new(),
822 data_classes: Vec::new(),
823 tool_patterns: Vec::new(),
824 grant_source,
825 source_principal: None,
826 expires_at_ms: None,
827 delegation_id: None,
828 }
829 }
830
831 pub fn with_permissions(mut self, permissions: Vec<AccessPermission>) -> Self {
832 self.permissions = permissions;
833 self
834 }
835
836 pub fn with_effect(mut self, effect: AccessEffect) -> Self {
837 self.effect = effect;
838 self
839 }
840
841 pub fn with_data_classes(mut self, data_classes: Vec<DataClass>) -> Self {
842 self.data_classes = data_classes;
843 self
844 }
845
846 pub fn with_tool_patterns(mut self, tool_patterns: Vec<String>) -> Self {
847 self.tool_patterns = tool_patterns;
848 self
849 }
850
851 pub fn with_source_principal(mut self, source_principal: PrincipalRef) -> Self {
852 self.source_principal = Some(source_principal);
853 self
854 }
855
856 pub fn with_expires_at_ms(mut self, expires_at_ms: u64) -> Self {
857 self.expires_at_ms = Some(expires_at_ms);
858 self
859 }
860
861 pub fn with_delegation_id(mut self, delegation_id: impl Into<String>) -> Self {
862 self.delegation_id = Some(delegation_id.into());
863 self
864 }
865
866 pub fn has_permission(&self, permission: AccessPermission) -> bool {
867 self.permissions.contains(&permission)
868 }
869
870 pub fn allows_data_class(&self, data_class: DataClass) -> bool {
871 self.data_classes.contains(&data_class)
872 }
873
874 pub fn is_expired_at(&self, now_ms: u64) -> bool {
875 self.expires_at_ms
876 .map(|expires_at_ms| expires_at_ms <= now_ms)
877 .unwrap_or(false)
878 }
879
880 pub fn applies_to(
881 &self,
882 resource: &ResourceRef,
883 permission: AccessPermission,
884 data_class: DataClass,
885 now_ms: u64,
886 ) -> bool {
887 !self.is_expired_at(now_ms)
888 && self.has_permission(permission)
889 && self.allows_data_class(data_class)
890 && self.resource.applies_to(resource)
891 }
892}
893
894#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
895pub struct LocalImplicitTenant;
896
897impl LocalImplicitTenant {
898 pub const ORG_ID: &'static str = "local";
899 pub const WORKSPACE_ID: &'static str = "local";
900}
901
902#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
903pub struct TenantContext {
904 pub org_id: String,
905 pub workspace_id: String,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub deployment_id: Option<String>,
908 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub actor_id: Option<String>,
910 #[serde(default)]
911 pub source: TenantSource,
912}
913
914impl Default for TenantContext {
915 fn default() -> Self {
916 Self::local_implicit()
917 }
918}
919
920impl TenantContext {
921 pub fn local_implicit() -> Self {
922 Self {
923 org_id: LocalImplicitTenant::ORG_ID.to_string(),
924 workspace_id: LocalImplicitTenant::WORKSPACE_ID.to_string(),
925 deployment_id: None,
926 actor_id: None,
927 source: TenantSource::LocalImplicit,
928 }
929 }
930
931 pub fn explicit(
932 org_id: impl Into<String>,
933 workspace_id: impl Into<String>,
934 actor_id: Option<String>,
935 ) -> Self {
936 Self {
937 org_id: org_id.into(),
938 workspace_id: workspace_id.into(),
939 deployment_id: None,
940 actor_id,
941 source: TenantSource::Explicit,
942 }
943 }
944
945 pub fn explicit_user_workspace(
946 org_id: impl Into<String>,
947 workspace_id: impl Into<String>,
948 deployment_id: Option<String>,
949 actor_id: impl Into<String>,
950 ) -> Self {
951 Self {
952 org_id: org_id.into(),
953 workspace_id: workspace_id.into(),
954 deployment_id,
955 actor_id: Some(actor_id.into()),
956 source: TenantSource::Explicit,
957 }
958 }
959
960 pub fn is_local_implicit(&self) -> bool {
961 self.source == TenantSource::LocalImplicit
962 && self.org_id == LocalImplicitTenant::ORG_ID
963 && self.workspace_id == LocalImplicitTenant::WORKSPACE_ID
964 && self.deployment_id.is_none()
965 && self.actor_id.is_none()
966 }
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970pub struct VerifiedTenantContext {
971 pub tenant_context: TenantContext,
972 pub human_actor: HumanActor,
973 pub authority_chain: AuthorityChain,
974 #[serde(default, skip_serializing_if = "Vec::is_empty")]
975 pub roles: Vec<String>,
976 #[serde(default, skip_serializing_if = "Vec::is_empty")]
977 pub org_units: Vec<String>,
978 #[serde(default, skip_serializing_if = "Vec::is_empty")]
979 pub capabilities: Vec<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub policy_version: Option<u64>,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
983 pub strict_projection: Option<StrictTenantContext>,
984 pub issuer: String,
985 pub audience: String,
986 pub issued_at_ms: u64,
987 pub expires_at_ms: u64,
988 pub assertion_id: String,
989}
990
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992pub struct TenantContextAssertionHeader {
993 pub alg: String,
994 pub typ: String,
995 pub kid: String,
996}
997
998impl TenantContextAssertionHeader {
999 pub fn ed25519(key_id: impl Into<String>) -> Self {
1000 Self {
1001 alg: "EdDSA".to_string(),
1002 typ: "tandem-tenant-context+jws".to_string(),
1003 kid: key_id.into(),
1004 }
1005 }
1006}
1007
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1009pub struct TenantContextAssertionClaims {
1010 pub version: String,
1011 pub issuer: String,
1012 pub audience: String,
1013 pub issued_at_ms: u64,
1014 pub expires_at_ms: u64,
1015 pub assertion_id: String,
1016 pub tenant_context: TenantContext,
1017 pub human_actor: HumanActor,
1018 pub authority_chain: AuthorityChain,
1019 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1020 pub roles: Vec<String>,
1021 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1022 pub org_units: Vec<String>,
1023 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1024 pub capabilities: Vec<String>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub policy_version: Option<u64>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub principal: Option<PrincipalRef>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub resource_scope: Option<ResourceScope>,
1031 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1032 pub grants: Vec<ScopedGrant>,
1033 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub data_boundary: Option<DataBoundary>,
1035}
1036
1037impl TenantContextAssertionClaims {
1038 #[allow(clippy::too_many_arguments)]
1039 pub fn new_v1(
1040 issuer: impl Into<String>,
1041 audience: impl Into<String>,
1042 issued_at_ms: u64,
1043 expires_at_ms: u64,
1044 assertion_id: impl Into<String>,
1045 tenant_context: TenantContext,
1046 human_actor: HumanActor,
1047 authority_chain: AuthorityChain,
1048 roles: Vec<String>,
1049 ) -> Self {
1050 Self {
1051 version: "v1".to_string(),
1052 issuer: issuer.into(),
1053 audience: audience.into(),
1054 issued_at_ms,
1055 expires_at_ms,
1056 assertion_id: assertion_id.into(),
1057 tenant_context,
1058 human_actor,
1059 authority_chain,
1060 roles,
1061 org_units: Vec::new(),
1062 capabilities: Vec::new(),
1063 policy_version: None,
1064 principal: None,
1065 resource_scope: None,
1066 grants: Vec::new(),
1067 data_boundary: None,
1068 }
1069 }
1070
1071 pub fn is_expired_at(&self, now_ms: u64) -> bool {
1072 self.expires_at_ms <= now_ms
1073 }
1074
1075 pub fn with_strict_projection(
1076 mut self,
1077 principal: PrincipalRef,
1078 resource_scope: ResourceScope,
1079 grants: Vec<ScopedGrant>,
1080 data_boundary: DataBoundary,
1081 ) -> Self {
1082 self.principal = Some(principal);
1083 self.resource_scope = Some(resource_scope);
1084 self.grants = grants;
1085 self.data_boundary = Some(data_boundary);
1086 self
1087 }
1088
1089 pub fn has_strict_projection(&self) -> bool {
1090 self.principal.is_some()
1091 || self.resource_scope.is_some()
1092 || !self.grants.is_empty()
1093 || self.data_boundary.is_some()
1094 }
1095
1096 pub fn strict_projection(&self) -> Option<StrictTenantContext> {
1097 Some(
1098 StrictTenantContext::new(
1099 self.tenant_context.clone(),
1100 self.principal.clone()?,
1101 self.authority_chain.clone(),
1102 self.resource_scope.clone()?,
1103 AssertionMetadata::from(self),
1104 )
1105 .with_grants(self.grants.clone())
1106 .with_data_boundary(self.data_boundary.clone().unwrap_or_default()),
1107 )
1108 }
1109}
1110
1111impl From<&TenantContextAssertionClaims> for AssertionMetadata {
1112 fn from(claims: &TenantContextAssertionClaims) -> Self {
1113 Self {
1114 issuer: claims.issuer.clone(),
1115 audience: claims.audience.clone(),
1116 issued_at_ms: claims.issued_at_ms,
1117 expires_at_ms: claims.expires_at_ms,
1118 assertion_id: claims.assertion_id.clone(),
1119 key_id: None,
1120 purpose: Some(SigningKeyPurpose::ContextAssertion),
1121 }
1122 }
1123}
1124
1125impl From<TenantContextAssertionClaims> for VerifiedTenantContext {
1126 fn from(claims: TenantContextAssertionClaims) -> Self {
1127 let strict_projection = claims.strict_projection();
1128 Self {
1129 tenant_context: claims.tenant_context,
1130 human_actor: claims.human_actor,
1131 authority_chain: claims.authority_chain,
1132 roles: claims.roles,
1133 org_units: claims.org_units,
1134 capabilities: claims.capabilities,
1135 policy_version: claims.policy_version,
1136 strict_projection,
1137 issuer: claims.issuer,
1138 audience: claims.audience,
1139 issued_at_ms: claims.issued_at_ms,
1140 expires_at_ms: claims.expires_at_ms,
1141 assertion_id: claims.assertion_id,
1142 }
1143 }
1144}
1145
1146impl VerifiedTenantContext {
1147 pub fn is_expired_at(&self, now_ms: u64) -> bool {
1148 self.expires_at_ms <= now_ms
1149 }
1150
1151 pub fn tenant_matches(&self, tenant: &TenantContext) -> bool {
1152 self.tenant_context.org_id == tenant.org_id
1153 && self.tenant_context.workspace_id == tenant.workspace_id
1154 && self.tenant_context.deployment_id == tenant.deployment_id
1155 }
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1159pub struct DataBoundary {
1160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1161 pub allowed_data_classes: Vec<DataClass>,
1162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1163 pub denied_data_classes: Vec<DataClass>,
1164}
1165
1166impl DataBoundary {
1167 pub fn unrestricted() -> Self {
1168 Self {
1169 allowed_data_classes: Vec::new(),
1170 denied_data_classes: Vec::new(),
1171 }
1172 }
1173
1174 pub fn allow(data_classes: Vec<DataClass>) -> Self {
1175 Self {
1176 allowed_data_classes: data_classes,
1177 denied_data_classes: Vec::new(),
1178 }
1179 }
1180
1181 pub fn allows(&self, data_class: DataClass) -> bool {
1182 if self.denied_data_classes.contains(&data_class) {
1183 return false;
1184 }
1185 self.allowed_data_classes.is_empty() || self.allowed_data_classes.contains(&data_class)
1186 }
1187}
1188
1189impl Default for DataBoundary {
1190 fn default() -> Self {
1191 Self::unrestricted()
1192 }
1193}
1194
1195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1196#[serde(rename_all = "snake_case")]
1197pub enum SigningKeyPurpose {
1198 ContextAssertion,
1199 ApprovalReceipt,
1200 DelegationProjection,
1201 A2aPeerAssertion,
1202 BreakGlassAdminAssertion,
1203}
1204
1205impl SigningKeyPurpose {
1206 pub fn as_str(self) -> &'static str {
1207 match self {
1208 Self::ContextAssertion => "context_assertion",
1209 Self::ApprovalReceipt => "approval_receipt",
1210 Self::DelegationProjection => "delegation_projection",
1211 Self::A2aPeerAssertion => "a2a_peer_assertion",
1212 Self::BreakGlassAdminAssertion => "break_glass_admin_assertion",
1213 }
1214 }
1215
1216 pub fn parse(value: &str) -> Result<Self, ParseSigningKeyPurposeError> {
1217 value.parse()
1218 }
1219}
1220
1221impl core::str::FromStr for SigningKeyPurpose {
1222 type Err = ParseSigningKeyPurposeError;
1223
1224 fn from_str(value: &str) -> Result<Self, Self::Err> {
1225 match value.trim().to_ascii_lowercase().as_str() {
1226 "context_assertion"
1227 | "context-assertion"
1228 | "tenant_context_assertion"
1229 | "tenant-context-assertion" => Ok(Self::ContextAssertion),
1230 "approval_receipt" | "approval-receipt" => Ok(Self::ApprovalReceipt),
1231 "delegation_projection" | "delegation-projection" => Ok(Self::DelegationProjection),
1232 "a2a_peer_assertion"
1233 | "a2a-peer-assertion"
1234 | "agent2agent_peer_assertion"
1235 | "agent2agent-peer-assertion" => Ok(Self::A2aPeerAssertion),
1236 "break_glass_admin_assertion"
1237 | "break-glass-admin-assertion"
1238 | "break_glass_admin"
1239 | "break-glass-admin" => Ok(Self::BreakGlassAdminAssertion),
1240 _ => Err(ParseSigningKeyPurposeError),
1241 }
1242 }
1243}
1244
1245impl core::fmt::Display for SigningKeyPurpose {
1246 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1247 f.write_str(self.as_str())
1248 }
1249}
1250
1251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub struct ParseSigningKeyPurposeError;
1253
1254impl core::fmt::Display for ParseSigningKeyPurposeError {
1255 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1256 f.write_str("invalid signing key purpose")
1257 }
1258}
1259
1260impl std::error::Error for ParseSigningKeyPurposeError {}
1261
1262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1263pub struct AssertionMetadata {
1264 pub issuer: String,
1265 pub audience: String,
1266 pub issued_at_ms: u64,
1267 pub expires_at_ms: u64,
1268 pub assertion_id: String,
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub key_id: Option<String>,
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub purpose: Option<SigningKeyPurpose>,
1273}
1274
1275impl AssertionMetadata {
1276 pub fn new(
1277 issuer: impl Into<String>,
1278 audience: impl Into<String>,
1279 issued_at_ms: u64,
1280 expires_at_ms: u64,
1281 assertion_id: impl Into<String>,
1282 ) -> Self {
1283 Self {
1284 issuer: issuer.into(),
1285 audience: audience.into(),
1286 issued_at_ms,
1287 expires_at_ms,
1288 assertion_id: assertion_id.into(),
1289 key_id: None,
1290 purpose: None,
1291 }
1292 }
1293
1294 pub fn with_key_id(mut self, key_id: impl Into<String>) -> Self {
1295 self.key_id = Some(key_id.into());
1296 self
1297 }
1298
1299 pub fn with_purpose(mut self, purpose: SigningKeyPurpose) -> Self {
1300 self.purpose = Some(purpose);
1301 self
1302 }
1303
1304 pub fn is_expired_at(&self, now_ms: u64) -> bool {
1305 self.expires_at_ms <= now_ms
1306 }
1307}
1308
1309impl From<&VerifiedTenantContext> for AssertionMetadata {
1310 fn from(context: &VerifiedTenantContext) -> Self {
1311 Self {
1312 issuer: context.issuer.clone(),
1313 audience: context.audience.clone(),
1314 issued_at_ms: context.issued_at_ms,
1315 expires_at_ms: context.expires_at_ms,
1316 assertion_id: context.assertion_id.clone(),
1317 key_id: None,
1318 purpose: Some(SigningKeyPurpose::ContextAssertion),
1319 }
1320 }
1321}
1322
1323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1324pub struct StrictTenantContext {
1325 pub tenant_context: TenantContext,
1326 pub principal: PrincipalRef,
1327 pub authority_chain: AuthorityChain,
1328 pub resource_scope: ResourceScope,
1329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1330 pub grants: Vec<ScopedGrant>,
1331 #[serde(default)]
1332 pub data_boundary: DataBoundary,
1333 pub assertion: AssertionMetadata,
1334}
1335
1336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1337#[serde(rename_all = "snake_case")]
1338pub enum AccessDecision {
1339 Allow,
1340 Deny,
1341 NotApplicable,
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1345pub struct GrantEvaluation {
1346 pub decision: AccessDecision,
1347 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 pub grant_id: Option<String>,
1349 pub reason: String,
1350}
1351
1352impl GrantEvaluation {
1353 pub fn allow(grant_id: impl Into<String>) -> Self {
1354 Self {
1355 decision: AccessDecision::Allow,
1356 grant_id: Some(grant_id.into()),
1357 reason: "matching_allow_grant".to_string(),
1358 }
1359 }
1360
1361 pub fn deny(reason: impl Into<String>, grant_id: Option<String>) -> Self {
1362 Self {
1363 decision: AccessDecision::Deny,
1364 grant_id,
1365 reason: reason.into(),
1366 }
1367 }
1368
1369 pub fn not_applicable(reason: impl Into<String>) -> Self {
1370 Self {
1371 decision: AccessDecision::NotApplicable,
1372 grant_id: None,
1373 reason: reason.into(),
1374 }
1375 }
1376}
1377
1378impl StrictTenantContext {
1379 pub fn new(
1380 tenant_context: TenantContext,
1381 principal: PrincipalRef,
1382 authority_chain: AuthorityChain,
1383 resource_scope: ResourceScope,
1384 assertion: AssertionMetadata,
1385 ) -> Self {
1386 Self {
1387 tenant_context,
1388 principal,
1389 authority_chain,
1390 resource_scope,
1391 grants: Vec::new(),
1392 data_boundary: DataBoundary::default(),
1393 assertion,
1394 }
1395 }
1396
1397 pub fn with_grants(mut self, grants: Vec<ScopedGrant>) -> Self {
1398 self.grants = grants;
1399 self
1400 }
1401
1402 pub fn with_data_boundary(mut self, data_boundary: DataBoundary) -> Self {
1403 self.data_boundary = data_boundary;
1404 self
1405 }
1406
1407 pub fn is_expired_at(&self, now_ms: u64) -> bool {
1408 self.assertion.is_expired_at(now_ms)
1409 }
1410
1411 pub fn allows_data_class(&self, data_class: DataClass) -> bool {
1412 self.data_boundary.allows(data_class)
1413 && self
1414 .grants
1415 .iter()
1416 .any(|grant| grant.allows_data_class(data_class))
1417 }
1418
1419 pub fn has_permission(&self, permission: AccessPermission) -> bool {
1420 self.grants
1421 .iter()
1422 .any(|grant| grant.has_permission(permission))
1423 }
1424
1425 pub fn evaluate_access(
1426 &self,
1427 resource: &ResourceRef,
1428 permission: AccessPermission,
1429 data_class: DataClass,
1430 now_ms: u64,
1431 ) -> GrantEvaluation {
1432 if self.is_expired_at(now_ms) {
1433 return GrantEvaluation::deny("context_expired", None);
1434 }
1435 if !self.data_boundary.allows(data_class) {
1436 return GrantEvaluation::deny("data_class_denied_by_boundary", None);
1437 }
1438 if self.resource_scope.explicitly_denies(resource) {
1439 return GrantEvaluation::deny("resource_explicitly_denied_by_scope", None);
1440 }
1441
1442 if let Some(grant) = self.grants.iter().find(|grant| {
1443 grant.effect == AccessEffect::Deny
1444 && grant.applies_to(resource, permission, data_class, now_ms)
1445 }) {
1446 return GrantEvaluation::deny("matching_deny_grant", Some(grant.grant_id.clone()));
1447 }
1448
1449 if !self.resource_scope.contains(resource) {
1450 return GrantEvaluation::not_applicable("resource_outside_projected_scope");
1451 }
1452
1453 if let Some(grant) = self.grants.iter().find(|grant| {
1454 grant.effect == AccessEffect::Allow
1455 && grant.applies_to(resource, permission, data_class, now_ms)
1456 }) {
1457 return GrantEvaluation::allow(grant.grant_id.clone());
1458 }
1459
1460 GrantEvaluation::not_applicable("no_matching_allow_grant")
1461 }
1462}
1463
1464impl From<LocalImplicitTenant> for TenantContext {
1465 fn from(_: LocalImplicitTenant) -> Self {
1466 Self::local_implicit()
1467 }
1468}
1469
1470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1471pub struct SecretRef {
1472 pub org_id: String,
1473 pub workspace_id: String,
1474 pub provider: String,
1475 pub secret_id: String,
1476 pub name: String,
1477}
1478
1479impl SecretRef {
1480 pub fn validate_for_tenant(&self, ctx: &TenantContext) -> Result<(), SecretRefError> {
1481 if self.org_id != ctx.org_id {
1482 return Err(SecretRefError::OrgMismatch);
1483 }
1484 if self.workspace_id != ctx.workspace_id {
1485 return Err(SecretRefError::WorkspaceMismatch);
1486 }
1487 Ok(())
1488 }
1489}
1490
1491#[derive(Debug, Clone, PartialEq, Eq)]
1492pub enum SecretRefError {
1493 OrgMismatch,
1494 WorkspaceMismatch,
1495 NotFound,
1496}
1497
1498impl core::fmt::Display for SecretRefError {
1499 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1500 match self {
1501 Self::OrgMismatch => write!(f, "secret org does not match request context"),
1502 Self::WorkspaceMismatch => write!(f, "secret workspace does not match request context"),
1503 Self::NotFound => write!(f, "secret not found"),
1504 }
1505 }
1506}
1507
1508impl std::error::Error for SecretRefError {}
1509
1510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1511#[serde(rename_all = "snake_case")]
1512pub enum ConnectorLifecycleState {
1513 #[default]
1514 Active,
1515 Paused,
1516 Revoked,
1517 Quarantined,
1518}
1519
1520impl ConnectorLifecycleState {
1521 pub fn allows_ingestion(self) -> bool {
1522 matches!(self, Self::Active)
1523 }
1524}
1525
1526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1527#[serde(rename_all = "snake_case")]
1528pub enum ConnectorCredentialClass {
1529 #[default]
1530 ReadOnly,
1531 ReadWrite,
1532 Admin,
1533}
1534
1535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1536pub struct ConnectorCredentialRef {
1537 pub org_id: String,
1538 pub workspace_id: String,
1539 pub connector_id: String,
1540 pub credential_id: String,
1541 #[serde(default)]
1542 pub credential_class: ConnectorCredentialClass,
1543 pub secret_ref: SecretRef,
1544 #[serde(default, skip_serializing_if = "Option::is_none")]
1545 pub source_bound_resource: Option<ResourceRef>,
1546 pub created_at_ms: u64,
1547 #[serde(default, skip_serializing_if = "Option::is_none")]
1548 pub rotated_at_ms: Option<u64>,
1549 #[serde(default, skip_serializing_if = "Option::is_none")]
1550 pub expires_at_ms: Option<u64>,
1551}
1552
1553impl ConnectorCredentialRef {
1554 pub fn read_only(
1555 org_id: impl Into<String>,
1556 workspace_id: impl Into<String>,
1557 connector_id: impl Into<String>,
1558 credential_id: impl Into<String>,
1559 secret_ref: SecretRef,
1560 created_at_ms: u64,
1561 ) -> Self {
1562 Self {
1563 org_id: org_id.into(),
1564 workspace_id: workspace_id.into(),
1565 connector_id: connector_id.into(),
1566 credential_id: credential_id.into(),
1567 credential_class: ConnectorCredentialClass::ReadOnly,
1568 secret_ref,
1569 source_bound_resource: None,
1570 created_at_ms,
1571 rotated_at_ms: None,
1572 expires_at_ms: None,
1573 }
1574 }
1575
1576 pub fn with_source_bound_resource(mut self, resource: ResourceRef) -> Self {
1577 self.source_bound_resource = Some(resource);
1578 self
1579 }
1580
1581 pub fn validate_for_tenant(&self, ctx: &TenantContext) -> Result<(), SecretRefError> {
1582 if self.org_id != ctx.org_id {
1583 return Err(SecretRefError::OrgMismatch);
1584 }
1585 if self.workspace_id != ctx.workspace_id {
1586 return Err(SecretRefError::WorkspaceMismatch);
1587 }
1588 self.secret_ref.validate_for_tenant(ctx)
1589 }
1590}
1591
1592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1593pub struct ConnectorInstance {
1594 pub connector_id: String,
1595 pub tenant_context: TenantContext,
1596 pub provider: String,
1597 #[serde(default, skip_serializing_if = "Option::is_none")]
1598 pub display_name: Option<String>,
1599 #[serde(default)]
1600 pub state: ConnectorLifecycleState,
1601 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1602 pub credential_refs: Vec<ConnectorCredentialRef>,
1603 pub created_by: PrincipalRef,
1604 pub created_at_ms: u64,
1605 pub updated_at_ms: u64,
1606}
1607
1608impl ConnectorInstance {
1609 pub fn active(
1610 connector_id: impl Into<String>,
1611 tenant_context: TenantContext,
1612 provider: impl Into<String>,
1613 created_by: PrincipalRef,
1614 now_ms: u64,
1615 ) -> Self {
1616 Self {
1617 connector_id: connector_id.into(),
1618 tenant_context,
1619 provider: provider.into(),
1620 display_name: None,
1621 state: ConnectorLifecycleState::Active,
1622 credential_refs: Vec::new(),
1623 created_by,
1624 created_at_ms: now_ms,
1625 updated_at_ms: now_ms,
1626 }
1627 }
1628
1629 pub fn with_state(mut self, state: ConnectorLifecycleState, updated_at_ms: u64) -> Self {
1630 self.state = state;
1631 self.updated_at_ms = updated_at_ms;
1632 self
1633 }
1634
1635 pub fn with_credential_refs(mut self, credential_refs: Vec<ConnectorCredentialRef>) -> Self {
1636 self.credential_refs = credential_refs;
1637 self
1638 }
1639
1640 pub fn tenant_matches(&self, tenant: &TenantContext) -> bool {
1641 self.tenant_context.org_id == tenant.org_id
1642 && self.tenant_context.workspace_id == tenant.workspace_id
1643 && self.tenant_context.deployment_id == tenant.deployment_id
1644 }
1645}
1646
1647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1648#[serde(rename_all = "snake_case")]
1649pub enum SourceBindingState {
1650 #[default]
1651 Enabled,
1652 Disabled,
1653 Quarantined,
1654}
1655
1656impl SourceBindingState {
1657 pub fn allows_ingestion(self) -> bool {
1658 matches!(self, Self::Enabled)
1659 }
1660}
1661
1662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1663pub struct IngestionPolicy {
1664 #[serde(default = "default_true")]
1665 pub allow_indexing: bool,
1666 #[serde(default = "default_true")]
1667 pub allow_prompt_context: bool,
1668 #[serde(default)]
1669 pub require_review: bool,
1670 #[serde(default, skip_serializing_if = "Option::is_none")]
1671 pub max_depth: Option<u32>,
1672}
1673
1674impl Default for IngestionPolicy {
1675 fn default() -> Self {
1676 Self {
1677 allow_indexing: true,
1678 allow_prompt_context: true,
1679 require_review: false,
1680 max_depth: None,
1681 }
1682 }
1683}
1684
1685fn default_true() -> bool {
1686 true
1687}
1688
1689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1690pub struct SourceBinding {
1691 pub binding_id: String,
1692 pub tenant_context: TenantContext,
1693 pub connector_id: String,
1694 pub source_type: String,
1695 pub native_source_id: String,
1696 #[serde(default, skip_serializing_if = "Option::is_none")]
1697 pub source_root_label: Option<String>,
1698 pub resource_ref: ResourceRef,
1699 pub data_class: DataClass,
1700 #[serde(default)]
1701 pub state: SourceBindingState,
1702 #[serde(default, skip_serializing_if = "Option::is_none")]
1703 pub credential_ref_id: Option<String>,
1704 #[serde(default)]
1705 pub ingestion_policy: IngestionPolicy,
1706 pub created_by: PrincipalRef,
1707 pub created_at_ms: u64,
1708 pub updated_at_ms: u64,
1709}
1710
1711impl SourceBinding {
1712 #[allow(clippy::too_many_arguments)]
1713 pub fn enabled(
1714 binding_id: impl Into<String>,
1715 tenant_context: TenantContext,
1716 connector_id: impl Into<String>,
1717 source_type: impl Into<String>,
1718 native_source_id: impl Into<String>,
1719 resource_ref: ResourceRef,
1720 data_class: DataClass,
1721 created_by: PrincipalRef,
1722 now_ms: u64,
1723 ) -> Self {
1724 Self {
1725 binding_id: binding_id.into(),
1726 tenant_context,
1727 connector_id: connector_id.into(),
1728 source_type: source_type.into(),
1729 native_source_id: native_source_id.into(),
1730 source_root_label: None,
1731 resource_ref,
1732 data_class,
1733 state: SourceBindingState::Enabled,
1734 credential_ref_id: None,
1735 ingestion_policy: IngestionPolicy::default(),
1736 created_by,
1737 created_at_ms: now_ms,
1738 updated_at_ms: now_ms,
1739 }
1740 }
1741
1742 pub fn with_state(mut self, state: SourceBindingState, updated_at_ms: u64) -> Self {
1743 self.state = state;
1744 self.updated_at_ms = updated_at_ms;
1745 self
1746 }
1747
1748 pub fn with_credential_ref_id(mut self, credential_ref_id: impl Into<String>) -> Self {
1749 self.credential_ref_id = Some(credential_ref_id.into());
1750 self
1751 }
1752
1753 pub fn with_ingestion_policy(mut self, ingestion_policy: IngestionPolicy) -> Self {
1754 self.ingestion_policy = ingestion_policy;
1755 self
1756 }
1757
1758 pub fn tenant_matches(&self, tenant: &TenantContext) -> bool {
1759 self.tenant_context.org_id == tenant.org_id
1760 && self.tenant_context.workspace_id == tenant.workspace_id
1761 && self.tenant_context.deployment_id == tenant.deployment_id
1762 }
1763
1764 pub fn can_ingest_with(&self, connector: &ConnectorInstance) -> bool {
1765 self.connector_id == connector.connector_id
1766 && connector.tenant_matches(&self.tenant_context)
1767 && connector.state.allows_ingestion()
1768 && self.state.allows_ingestion()
1769 && self.ingestion_policy.allow_indexing
1770 }
1771}
1772
1773#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1774pub struct SourceObject {
1775 pub source_object_id: String,
1776 pub tenant_context: TenantContext,
1777 pub binding_id: String,
1778 pub connector_id: String,
1779 pub native_object_id: String,
1780 pub resource_ref: ResourceRef,
1781 pub data_class: DataClass,
1782 #[serde(default)]
1783 pub lifecycle_state: SourceObjectLifecycleState,
1784 #[serde(default, skip_serializing_if = "Option::is_none")]
1785 pub native_object_path: Option<String>,
1786 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub content_hash: Option<String>,
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 pub source_hash: Option<String>,
1790 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub parent_source_object_id: Option<String>,
1792 #[serde(default)]
1793 pub created_at_ms: u64,
1794 #[serde(default)]
1795 pub updated_at_ms: u64,
1796 #[serde(default, skip_serializing_if = "Option::is_none")]
1797 pub last_seen_at_ms: Option<u64>,
1798 #[serde(default, skip_serializing_if = "Option::is_none")]
1799 pub lifecycle_changed_at_ms: Option<u64>,
1800 #[serde(default, skip_serializing_if = "Option::is_none")]
1801 pub superseded_by_source_object_id: Option<String>,
1802}
1803
1804impl SourceObject {
1805 pub fn tenant_matches(&self, tenant: &TenantContext) -> bool {
1806 self.tenant_context.org_id == tenant.org_id
1807 && self.tenant_context.workspace_id == tenant.workspace_id
1808 && self.tenant_context.deployment_id == tenant.deployment_id
1809 }
1810
1811 pub fn is_active(&self) -> bool {
1812 self.lifecycle_state == SourceObjectLifecycleState::Active
1813 }
1814
1815 pub fn allows_prompt_context(&self) -> bool {
1816 self.is_active()
1817 }
1818
1819 pub fn with_lifecycle_state(
1820 mut self,
1821 lifecycle_state: SourceObjectLifecycleState,
1822 updated_at_ms: u64,
1823 ) -> Self {
1824 self.lifecycle_state = lifecycle_state;
1825 self.updated_at_ms = updated_at_ms;
1826 self.lifecycle_changed_at_ms = Some(updated_at_ms);
1827 self
1828 }
1829
1830 pub fn dedupe_scope_key(&self) -> String {
1831 format!(
1832 "{}:{}:{}:{}:{}:{}",
1833 self.tenant_context.org_id,
1834 self.tenant_context.workspace_id,
1835 self.resource_ref.resource_kind as u8,
1836 self.resource_ref.resource_id,
1837 self.binding_id,
1838 self.native_object_id
1839 )
1840 }
1841
1842 pub fn lifecycle_identity_key(&self) -> String {
1843 format!(
1844 "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
1845 self.tenant_context.org_id,
1846 self.tenant_context.workspace_id,
1847 self.tenant_context.deployment_id.as_deref().unwrap_or(""),
1848 self.binding_id,
1849 self.connector_id,
1850 self.resource_ref.resource_kind as u8,
1851 self.resource_ref.resource_id,
1852 self.resource_ref.path_prefix.as_deref().unwrap_or(""),
1853 self.data_class as u8,
1854 self.native_object_id,
1855 self.native_object_path.as_deref().unwrap_or("")
1856 )
1857 }
1858}
1859
1860#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1861#[serde(rename_all = "snake_case")]
1862pub enum SourceObjectLifecycleState {
1863 #[default]
1864 Active,
1865 Quarantined,
1866 Tombstoned,
1867 Deleted,
1868 Rescoped,
1869}
1870
1871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1872#[serde(rename_all = "snake_case")]
1873pub enum IngestionJobState {
1874 #[default]
1875 Queued,
1876 Running,
1877 Completed,
1878 Failed,
1879 Skipped,
1880 Quarantined,
1881}
1882
1883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1884pub struct IngestionJob {
1885 pub job_id: String,
1886 pub tenant_context: TenantContext,
1887 pub connector_id: String,
1888 pub binding_id: String,
1889 #[serde(default)]
1890 pub state: IngestionJobState,
1891 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1892 pub source_object_ids: Vec<String>,
1893 #[serde(default, skip_serializing_if = "Option::is_none")]
1894 pub started_at_ms: Option<u64>,
1895 #[serde(default, skip_serializing_if = "Option::is_none")]
1896 pub finished_at_ms: Option<u64>,
1897 #[serde(default, skip_serializing_if = "Option::is_none")]
1898 pub quarantine_id: Option<String>,
1899}
1900
1901#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1902#[serde(rename_all = "snake_case")]
1903pub enum QuarantineDisposition {
1904 Release,
1905 Delete,
1906 Reindex,
1907}
1908
1909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1910pub struct IngestionQuarantine {
1911 pub quarantine_id: String,
1912 pub tenant_context: TenantContext,
1913 pub connector_id: String,
1914 pub binding_id: String,
1915 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1916 pub source_object_ids: Vec<String>,
1917 pub reason: String,
1918 pub created_at_ms: u64,
1919 #[serde(default, skip_serializing_if = "Option::is_none")]
1920 pub reviewed_by: Option<PrincipalRef>,
1921 #[serde(default, skip_serializing_if = "Option::is_none")]
1922 pub reviewed_at_ms: Option<u64>,
1923 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 pub disposition: Option<QuarantineDisposition>,
1925}
1926
1927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1928pub struct ScopedMemoryChunkRef {
1929 pub chunk_id: String,
1930 pub tenant_context: TenantContext,
1931 pub source_object_id: String,
1932 pub resource_ref: ResourceRef,
1933 pub data_class: DataClass,
1934 #[serde(default, skip_serializing_if = "Option::is_none")]
1935 pub source_hash: Option<String>,
1936}
1937
1938pub trait TenantContextResolver: Send + Sync {
1939 fn resolve_tenant_context(
1940 &self,
1941 org_id: Option<&str>,
1942 workspace_id: Option<&str>,
1943 actor_id: Option<&str>,
1944 ) -> TenantContext;
1945}
1946
1947#[derive(Debug, Default, Clone, Copy)]
1948pub struct HeaderTenantContextResolver;
1949
1950impl TenantContextResolver for HeaderTenantContextResolver {
1951 fn resolve_tenant_context(
1952 &self,
1953 org_id: Option<&str>,
1954 workspace_id: Option<&str>,
1955 actor_id: Option<&str>,
1956 ) -> TenantContext {
1957 let org_id = org_id
1958 .map(str::trim)
1959 .filter(|value| !value.is_empty())
1960 .unwrap_or(LocalImplicitTenant::ORG_ID);
1961 let workspace_id = workspace_id
1962 .map(str::trim)
1963 .filter(|value| !value.is_empty())
1964 .unwrap_or(LocalImplicitTenant::WORKSPACE_ID);
1965 let actor_id = actor_id
1966 .map(str::trim)
1967 .filter(|value| !value.is_empty())
1968 .map(ToString::to_string);
1969
1970 if org_id == LocalImplicitTenant::ORG_ID
1971 && workspace_id == LocalImplicitTenant::WORKSPACE_ID
1972 && actor_id.is_none()
1973 {
1974 TenantContext::local_implicit()
1975 } else {
1976 TenantContext::explicit(org_id.to_string(), workspace_id.to_string(), actor_id)
1977 }
1978 }
1979}
1980
1981pub trait RequestAuthorizationHook: Send + Sync {
1982 fn authorize(&self, principal: &RequestPrincipal, tenant: &TenantContext) -> bool;
1983}
1984
1985#[derive(Debug, Default, Clone, Copy)]
1986pub struct NoopRequestAuthorizationHook;
1987
1988impl RequestAuthorizationHook for NoopRequestAuthorizationHook {
1989 fn authorize(&self, _principal: &RequestPrincipal, _tenant: &TenantContext) -> bool {
1990 true
1991 }
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1995pub struct EnterpriseStatus {
1996 pub mode: EnterpriseMode,
1997 pub bridge_state: EnterpriseBridgeState,
1998 #[serde(default)]
1999 pub capabilities: Vec<EnterpriseCapability>,
2000 pub tenant_context: TenantContext,
2001 pub public_build: bool,
2002 pub contract_version: String,
2003 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2004 pub notes: Vec<String>,
2005}
2006
2007impl EnterpriseStatus {
2008 pub fn public_oss() -> Self {
2009 Self {
2010 mode: EnterpriseMode::Disabled,
2011 bridge_state: EnterpriseBridgeState::Absent,
2012 capabilities: vec![
2013 EnterpriseCapability::Status,
2014 EnterpriseCapability::TenantContext,
2015 ],
2016 tenant_context: TenantContext::local_implicit(),
2017 public_build: true,
2018 contract_version: "v1".to_string(),
2019 notes: vec![
2020 "enterprise bridge is not configured".to_string(),
2021 "OSS mode uses a local implicit tenant until enterprise mode is enabled"
2022 .to_string(),
2023 ],
2024 }
2025 }
2026}
2027
2028pub trait EnterpriseBridge: Send + Sync {
2029 fn status(&self) -> EnterpriseStatus;
2030}
2031
2032#[derive(Debug, Default, Clone, Copy)]
2033pub struct NoopEnterpriseBridge;
2034
2035impl EnterpriseBridge for NoopEnterpriseBridge {
2036 fn status(&self) -> EnterpriseStatus {
2037 EnterpriseStatus::public_oss()
2038 }
2039}
2040
2041#[cfg(test)]
2042mod tests {
2043 use super::*;
2044
2045 #[test]
2046 fn secret_ref_validation_rejects_cross_tenant_access() {
2047 let secret_ref = SecretRef {
2048 org_id: "org-a".to_string(),
2049 workspace_id: "workspace-a".to_string(),
2050 provider: "mcp_header".to_string(),
2051 secret_id: "secret-a".to_string(),
2052 name: "authorization".to_string(),
2053 };
2054 let tenant = TenantContext::explicit("org-a", "workspace-a", None);
2055 assert!(secret_ref.validate_for_tenant(&tenant).is_ok());
2056
2057 let wrong_workspace = TenantContext::explicit("org-a", "workspace-b", None);
2058 assert!(matches!(
2059 secret_ref.validate_for_tenant(&wrong_workspace),
2060 Err(SecretRefError::WorkspaceMismatch)
2061 ));
2062 }
2063
2064 #[test]
2065 fn explicit_user_workspace_preserves_actor_and_deployment() {
2066 let tenant = TenantContext::explicit_user_workspace(
2067 "org-a",
2068 "workspace-a",
2069 Some("deployment-a".to_string()),
2070 "user-a",
2071 );
2072
2073 assert_eq!(tenant.org_id, "org-a");
2074 assert_eq!(tenant.workspace_id, "workspace-a");
2075 assert_eq!(tenant.deployment_id.as_deref(), Some("deployment-a"));
2076 assert_eq!(tenant.actor_id.as_deref(), Some("user-a"));
2077 assert_eq!(tenant.source, TenantSource::Explicit);
2078 assert!(!tenant.is_local_implicit());
2079 }
2080
2081 #[test]
2082 fn authority_chain_from_request_executes_as_same_actor() {
2083 let principal = RequestPrincipal::authenticated_user("user-a", "tandem_web");
2084 let chain = AuthorityChain::from_request(principal.clone());
2085
2086 assert_eq!(chain.initiated_by, principal);
2087 assert!(chain.owned_by.is_none());
2088 assert!(chain.approved_by.is_none());
2089 assert_eq!(chain.executed_as, ExecutionPrincipal::Request(principal));
2090 }
2091
2092 #[test]
2093 fn verified_tenant_context_checks_expiry_and_tenant_match() {
2094 let tenant = TenantContext::explicit_user_workspace(
2095 "org-a",
2096 "workspace-a",
2097 Some("deployment-a".to_string()),
2098 "user-a",
2099 );
2100 let actor = HumanActor::tandem_user("user-a");
2101 let principal = RequestPrincipal::authenticated_user("user-a", "tandem_web");
2102 let verified = VerifiedTenantContext {
2103 tenant_context: tenant.clone(),
2104 human_actor: actor,
2105 authority_chain: AuthorityChain::from_request(principal),
2106 roles: vec!["owner".to_string()],
2107 org_units: Vec::new(),
2108 capabilities: Vec::new(),
2109 policy_version: None,
2110 strict_projection: None,
2111 issuer: "tandem-web".to_string(),
2112 audience: "tandem-runtime".to_string(),
2113 issued_at_ms: 100,
2114 expires_at_ms: 200,
2115 assertion_id: "assertion-1".to_string(),
2116 };
2117
2118 assert!(!verified.is_expired_at(199));
2119 assert!(verified.is_expired_at(200));
2120 assert!(verified.tenant_matches(&tenant));
2121 assert!(!verified.tenant_matches(&TenantContext::explicit(
2122 "org-b",
2123 "workspace-a",
2124 Some("user-a".to_string()),
2125 )));
2126 }
2127
2128 #[test]
2129 fn runtime_auth_mode_parses_operator_aliases() {
2130 assert_eq!(
2131 RuntimeAuthMode::parse("local"),
2132 Ok(RuntimeAuthMode::LocalSingleTenant)
2133 );
2134 assert_eq!(
2135 RuntimeAuthMode::parse("hosted-single-tenant"),
2136 Ok(RuntimeAuthMode::HostedSingleTenant)
2137 );
2138 assert_eq!(
2139 RuntimeAuthMode::parse("enterprise_required"),
2140 Ok(RuntimeAuthMode::EnterpriseRequired)
2141 );
2142 assert!(RuntimeAuthMode::parse("definitely-not-a-mode").is_err());
2143 assert_eq!(
2144 RuntimeAuthMode::EnterpriseRequired.to_string(),
2145 "enterprise_required"
2146 );
2147 }
2148
2149 #[test]
2150 fn tenant_context_assertion_claims_convert_to_verified_context() {
2151 let tenant = TenantContext::explicit_user_workspace(
2152 "org-a",
2153 "workspace-a",
2154 Some("deployment-a".to_string()),
2155 "user-a",
2156 );
2157 let actor = HumanActor::tandem_user("user-a");
2158 let principal = RequestPrincipal::authenticated_user("user-a", "tandem_web");
2159 let chain = AuthorityChain::from_request(principal);
2160 let claims = TenantContextAssertionClaims::new_v1(
2161 "tandem-web",
2162 "tandem-runtime",
2163 100,
2164 200,
2165 "assertion-1",
2166 tenant.clone(),
2167 actor.clone(),
2168 chain.clone(),
2169 vec!["operator".to_string(), "approver".to_string()],
2170 );
2171
2172 assert_eq!(claims.version, "v1");
2173 assert!(!claims.is_expired_at(199));
2174 assert!(claims.is_expired_at(200));
2175
2176 let verified = VerifiedTenantContext::from(claims);
2177 assert_eq!(verified.tenant_context, tenant);
2178 assert_eq!(verified.human_actor, actor);
2179 assert_eq!(verified.authority_chain, chain);
2180 assert_eq!(verified.roles, vec!["operator", "approver"]);
2181 assert_eq!(verified.issuer, "tandem-web");
2182 assert_eq!(verified.audience, "tandem-runtime");
2183 assert_eq!(verified.assertion_id, "assertion-1");
2184 }
2185
2186 #[test]
2187 fn tenant_context_assertion_claims_can_carry_strict_projection() {
2188 let tenant = TenantContext::explicit_user_workspace(
2189 "acme",
2190 "engineering",
2191 Some("deployment-prod".to_string()),
2192 "user-eng",
2193 );
2194 let actor = HumanActor::tandem_user("user-eng");
2195 let request_principal = RequestPrincipal::authenticated_user("user-eng", "tandem-web");
2196 let authority_chain = AuthorityChain::from_request(request_principal);
2197 let principal =
2198 PrincipalRef::agent_worker("agent-platform").with_tenant_actor_id("user-eng");
2199 let project = ResourceRef::new("acme", "engineering", ResourceKind::Project, "platform");
2200 let repo = ResourceRef::new("acme", "engineering", ResourceKind::Repository, "tandem")
2201 .with_project_id("platform")
2202 .with_path_prefix("crates/tandem-enterprise-contract/");
2203 let grant = ScopedGrant::new(
2204 "grant-platform-read",
2205 principal.clone(),
2206 repo.clone(),
2207 GrantSource::Delegation,
2208 )
2209 .with_permissions(vec![AccessPermission::View, AccessPermission::Read])
2210 .with_data_classes(vec![DataClass::SourceCode])
2211 .with_delegation_id("delegation-platform");
2212
2213 let claims = TenantContextAssertionClaims::new_v1(
2214 "tandem-web",
2215 "tandem-runtime",
2216 1_000,
2217 2_000,
2218 "assertion-platform",
2219 tenant.clone(),
2220 actor,
2221 authority_chain,
2222 vec![],
2223 )
2224 .with_strict_projection(
2225 principal.clone(),
2226 ResourceScope {
2227 root: project,
2228 allowed_resources: vec![repo.clone()],
2229 denied_resources: Vec::new(),
2230 max_depth: Some(4),
2231 },
2232 vec![grant],
2233 DataBoundary::allow(vec![DataClass::SourceCode]),
2234 );
2235
2236 assert!(claims.has_strict_projection());
2237 let encoded = serde_json::to_value(&claims).expect("serialize projected claims");
2238 assert_eq!(encoded["principal"]["kind"], "agent_worker");
2239 assert_eq!(
2240 encoded["resource_scope"]["allowed_resources"][0]["resource_kind"],
2241 "repository"
2242 );
2243 assert_eq!(encoded["grants"][0]["delegation_id"], "delegation-platform");
2244
2245 let decoded: TenantContextAssertionClaims =
2246 serde_json::from_value(encoded).expect("deserialize projected claims");
2247 let strict = decoded
2248 .strict_projection()
2249 .expect("strict projection should be present");
2250 assert_eq!(strict.tenant_context, tenant);
2251 assert_eq!(strict.principal, principal);
2252 assert_eq!(strict.grants[0].grant_id, "grant-platform-read");
2253 assert_eq!(strict.assertion.assertion_id, "assertion-platform");
2254 assert!(strict.allows_data_class(DataClass::SourceCode));
2255 assert!(!strict.allows_data_class(DataClass::Executive));
2256 }
2257
2258 #[test]
2259 fn tenant_context_assertion_claims_remain_backward_compatible_without_projection() {
2260 let legacy = serde_json::json!({
2261 "version": "v1",
2262 "issuer": "tandem-web",
2263 "audience": "tandem-runtime",
2264 "issued_at_ms": 1000,
2265 "expires_at_ms": 2000,
2266 "assertion_id": "assertion-legacy",
2267 "tenant_context": {
2268 "org_id": "acme",
2269 "workspace_id": "engineering",
2270 "deployment_id": "deployment-prod",
2271 "actor_id": "user-eng",
2272 "source": "explicit"
2273 },
2274 "human_actor": {
2275 "actor_id": "user-eng",
2276 "provider": "tandem"
2277 },
2278 "authority_chain": {
2279 "initiated_by": {
2280 "actor_id": "user-eng",
2281 "source": "tandem-web"
2282 },
2283 "executed_as": {
2284 "kind": "request",
2285 "actor_id": "user-eng",
2286 "source": "tandem-web"
2287 }
2288 }
2289 });
2290
2291 let claims: TenantContextAssertionClaims =
2292 serde_json::from_value(legacy).expect("legacy claims should deserialize");
2293 assert!(!claims.has_strict_projection());
2294 assert!(claims.strict_projection().is_none());
2295 assert!(claims.grants.is_empty());
2296 assert!(claims.data_boundary.is_none());
2297 }
2298
2299 #[test]
2300 fn tenant_context_assertion_header_uses_eddsa_jws_typ() {
2301 let header = TenantContextAssertionHeader::ed25519("key-1");
2302 assert_eq!(header.alg, "EdDSA");
2303 assert_eq!(header.typ, "tandem-tenant-context+jws");
2304 assert_eq!(header.kid, "key-1");
2305 }
2306
2307 #[test]
2308 fn header_resolver_defaults_to_local_tenant() {
2309 let resolver = HeaderTenantContextResolver;
2310 let tenant = resolver.resolve_tenant_context(None, None, None);
2311 assert!(tenant.is_local_implicit());
2312 }
2313
2314 #[test]
2315 fn request_authorization_hook_is_noop_by_default() {
2316 let hook = NoopRequestAuthorizationHook;
2317 let principal = RequestPrincipal::anonymous();
2318 let tenant = TenantContext::local_implicit();
2319 assert!(hook.authorize(&principal, &tenant));
2320 }
2321
2322 #[test]
2323 fn resource_ref_round_trips_finance_workspace_data_store() {
2324 let resource =
2325 ResourceRef::new("acme", "finance", ResourceKind::DataStore, "finance-ledger")
2326 .with_parent_path(vec![
2327 ResourcePathSegment::named(ResourceKind::Department, "finance", "Finance"),
2328 ResourcePathSegment::named(
2329 ResourceKind::SharedDrive,
2330 "finance-drive",
2331 "Finance",
2332 ),
2333 ]);
2334
2335 let encoded = serde_json::to_string(&resource).expect("serialize resource ref");
2336 assert!(encoded.contains("\"resource_kind\":\"data_store\""));
2337
2338 let decoded: ResourceRef =
2339 serde_json::from_str(&encoded).expect("deserialize resource ref");
2340 assert_eq!(decoded, resource);
2341 assert_eq!(decoded.organization_id, "acme");
2342 assert_eq!(decoded.workspace_id, "finance");
2343 assert_eq!(decoded.resource_kind, ResourceKind::DataStore);
2344 }
2345
2346 #[test]
2347 fn resource_scope_models_engineering_repo_path_scope() {
2348 let repository =
2349 ResourceRef::new("acme", "engineering", ResourceKind::Repository, "tandem")
2350 .with_project_id("platform")
2351 .with_branch_id("main")
2352 .with_path_prefix("crates/tandem-enterprise-contract/");
2353
2354 let scope = ResourceScope {
2355 root: ResourceRef::new("acme", "engineering", ResourceKind::Project, "platform"),
2356 allowed_resources: vec![repository.clone()],
2357 denied_resources: vec![ResourceRef::new(
2358 "acme",
2359 "engineering",
2360 ResourceKind::Directory,
2361 "secrets",
2362 )
2363 .with_project_id("platform")
2364 .with_path_prefix("crates/tandem-enterprise-contract/secrets/")],
2365 max_depth: Some(4),
2366 };
2367
2368 let encoded = serde_json::to_value(&scope).expect("serialize resource scope");
2369 assert_eq!(
2370 encoded["allowed_resources"][0]["resource_kind"],
2371 "repository"
2372 );
2373 assert_eq!(
2374 encoded["allowed_resources"][0]["path_prefix"],
2375 "crates/tandem-enterprise-contract/"
2376 );
2377
2378 let decoded: ResourceScope =
2379 serde_json::from_value(encoded).expect("deserialize resource scope");
2380 assert_eq!(decoded, scope);
2381 assert_eq!(decoded.allowed_resources, vec![repository]);
2382 }
2383
2384 #[test]
2385 fn resource_scope_models_ceo_org_wide_executive_access() {
2386 let principal = PrincipalRef::human_user("ceo-user")
2387 .with_tenant_actor_id("user-ceo")
2388 .with_issuer_subject("https://idp.acme.example", "00uceo");
2389 let scope = ResourceScope::root(ResourceRef::new(
2390 "acme",
2391 "*",
2392 ResourceKind::Organization,
2393 "acme",
2394 ));
2395
2396 assert_eq!(principal.kind, PrincipalKind::HumanUser);
2397 assert_eq!(principal.tenant_actor_id.as_deref(), Some("user-ceo"));
2398 assert_eq!(scope.root.resource_kind, ResourceKind::Organization);
2399 assert_eq!(scope.root.workspace_id, "*");
2400 assert!(scope.allowed_resources.is_empty());
2401
2402 let encoded = serde_json::to_string(&DataClass::Executive).expect("serialize data class");
2403 assert_eq!(encoded, "\"executive\"");
2404 }
2405
2406 #[test]
2407 fn mcp_tool_resource_target_and_permissions_are_transport_safe() {
2408 let tool = ResourceRef::new(
2409 "acme",
2410 "security",
2411 ResourceKind::McpTool,
2412 "mcp:google-drive:files.export",
2413 )
2414 .with_parent_path(vec![
2415 ResourcePathSegment::new(ResourceKind::McpServer, "google-drive"),
2416 ResourcePathSegment::new(ResourceKind::DataStore, "security-drive"),
2417 ]);
2418 let permissions = vec![AccessPermission::View, AccessPermission::Execute];
2419 let data_classes = vec![DataClass::Confidential, DataClass::Credential];
2420 let worker = PrincipalRef::agent_worker("agent-security-export");
2421
2422 let payload = serde_json::json!({
2423 "principal": worker,
2424 "resource": tool,
2425 "permissions": permissions,
2426 "data_classes": data_classes,
2427 });
2428
2429 assert_eq!(payload["principal"]["kind"], "agent_worker");
2430 assert_eq!(payload["resource"]["resource_kind"], "mcp_tool");
2431 assert_eq!(payload["permissions"][1], "execute");
2432 assert_eq!(payload["data_classes"][1], "credential");
2433 }
2434
2435 #[test]
2436 fn scoped_grant_models_department_membership_data_access() {
2437 let finance_department = PrincipalRef::new(PrincipalKind::Department, "finance");
2438 let finance_user =
2439 PrincipalRef::human_user("user-finance").with_tenant_actor_id("actor-finance");
2440 let finance_store =
2441 ResourceRef::new("acme", "finance", ResourceKind::DataStore, "finance-ledger");
2442 let grant = ScopedGrant::new(
2443 "grant-finance-ledger-read",
2444 finance_user,
2445 finance_store,
2446 GrantSource::DepartmentMembership,
2447 )
2448 .with_source_principal(finance_department)
2449 .with_permissions(vec![AccessPermission::View, AccessPermission::Read])
2450 .with_data_classes(vec![DataClass::FinancialRecord, DataClass::Confidential]);
2451
2452 assert_eq!(grant.grant_source, GrantSource::DepartmentMembership);
2453 assert!(grant.has_permission(AccessPermission::Read));
2454 assert!(!grant.has_permission(AccessPermission::Edit));
2455 assert!(grant.allows_data_class(DataClass::FinancialRecord));
2456 assert!(!grant.allows_data_class(DataClass::Executive));
2457
2458 let encoded = serde_json::to_value(&grant).expect("serialize department grant");
2459 assert_eq!(encoded["grant_source"], "department_membership");
2460 assert_eq!(encoded["source_principal"]["kind"], "department");
2461 }
2462
2463 #[test]
2464 fn scoped_grant_models_cross_functional_group_access() {
2465 let launch_group = PrincipalRef::new(PrincipalKind::Group, "launch-team");
2466 let marketer = PrincipalRef::human_user("user-marketing");
2467 let launch_room = ResourceRef::new("acme", "gtm", ResourceKind::DataRoom, "q4-launch-room");
2468 let grant = ScopedGrant::new(
2469 "grant-launch-room-edit",
2470 marketer,
2471 launch_room,
2472 GrantSource::GroupMembership,
2473 )
2474 .with_source_principal(launch_group)
2475 .with_permissions(vec![
2476 AccessPermission::View,
2477 AccessPermission::Read,
2478 AccessPermission::Edit,
2479 ])
2480 .with_data_classes(vec![DataClass::Internal, DataClass::CustomerData]);
2481
2482 let decoded: ScopedGrant =
2483 serde_json::from_value(serde_json::to_value(&grant).expect("serialize group grant"))
2484 .expect("deserialize group grant");
2485 assert_eq!(decoded, grant);
2486 assert_eq!(decoded.grant_source, GrantSource::GroupMembership);
2487 assert!(decoded.has_permission(AccessPermission::Edit));
2488 assert!(decoded.allows_data_class(DataClass::CustomerData));
2489 }
2490
2491 #[test]
2492 fn organization_unit_taxonomy_models_company_specific_domains() {
2493 let tenant = TenantContext::explicit_user_workspace(
2494 "clinic-co",
2495 "care-delivery",
2496 Some("deployment-prod".to_string()),
2497 "admin-user",
2498 );
2499 let admin = PrincipalRef::human_user("admin-user");
2500 let doctors = OrganizationUnit::active(
2501 "doctors",
2502 tenant.clone(),
2503 "Doctors",
2504 OrganizationUnitKind::ClinicalGroup,
2505 admin.clone(),
2506 1_000,
2507 )
2508 .with_taxonomy_id("clinical_role")
2509 .with_parent_unit_id("clinical");
2510 let consultants = OrganizationUnit::active(
2511 "consultants",
2512 tenant.clone(),
2513 "Consultants",
2514 OrganizationUnitKind::ContractorGroup,
2515 admin,
2516 1_000,
2517 );
2518
2519 assert_eq!(
2520 doctors.principal_ref().kind,
2521 PrincipalKind::OrganizationUnit
2522 );
2523 assert_eq!(doctors.principal_ref().id, "clinical_role/doctors");
2524 assert_eq!(
2525 doctors.resource_ref().resource_kind,
2526 ResourceKind::OrganizationUnit
2527 );
2528 assert_eq!(doctors.resource_ref().resource_id, "clinical_role/doctors");
2529 assert_eq!(doctors.parent_unit_id.as_deref(), Some("clinical"));
2530 assert_eq!(consultants.kind, OrganizationUnitKind::ContractorGroup);
2531
2532 let encoded = serde_json::to_value(&doctors).expect("serialize organization unit");
2533 assert_eq!(encoded["taxonomy_id"], "clinical_role");
2534 assert_eq!(encoded["kind"], "clinical_group");
2535 assert_eq!(encoded["state"], "active");
2536 assert_eq!(encoded["unit_id"], "doctors");
2537
2538 let decoded: OrganizationUnit =
2539 serde_json::from_value(encoded).expect("deserialize organization unit");
2540 assert_eq!(decoded, doctors);
2541 }
2542
2543 #[test]
2544 fn organization_unit_membership_feeds_scoped_grants_without_hardcoded_roles() {
2545 let tenant = TenantContext::explicit_user_workspace(
2546 "clinic-co",
2547 "care-delivery",
2548 Some("deployment-prod".to_string()),
2549 "doctor-user",
2550 );
2551 let doctors = PrincipalRef::organization_unit("clinical_role/doctors");
2552 let doctor = PrincipalRef::human_user("doctor-user");
2553 let membership = OrganizationUnitMembership::active(
2554 "membership-doctor-user",
2555 tenant,
2556 doctors.clone(),
2557 doctor.clone(),
2558 OrganizationUnitMembershipSource::HostedControlPlane,
2559 1_000,
2560 )
2561 .with_expires_at_ms(2_000);
2562 let patient_cases = ResourceRef::new(
2563 "clinic-co",
2564 "care-delivery",
2565 ResourceKind::DataStore,
2566 "patient-cases",
2567 );
2568 let grant = ScopedGrant::new(
2569 "grant-doctors-patient-cases",
2570 doctor,
2571 patient_cases.clone(),
2572 GrantSource::OrganizationUnitMembership,
2573 )
2574 .with_source_principal(doctors)
2575 .with_permissions(vec![AccessPermission::View, AccessPermission::Read])
2576 .with_data_classes(vec![DataClass::Regulated, DataClass::CustomerData]);
2577
2578 assert!(membership.is_active_at(1_999));
2579 assert!(!membership.is_active_at(2_000));
2580 assert_eq!(grant.grant_source, GrantSource::OrganizationUnitMembership);
2581 assert_eq!(
2582 grant.source_principal.as_ref().map(|source| source.kind),
2583 Some(PrincipalKind::OrganizationUnit)
2584 );
2585 assert!(grant.applies_to(
2586 &patient_cases,
2587 AccessPermission::Read,
2588 DataClass::Regulated,
2589 1_500
2590 ));
2591
2592 let encoded = serde_json::to_value(&grant).expect("serialize org unit grant");
2593 assert_eq!(encoded["grant_source"], "organization_unit_membership");
2594 assert_eq!(encoded["source_principal"]["kind"], "organization_unit");
2595 }
2596
2597 #[test]
2598 fn scoped_grant_models_explicit_executive_global_access() {
2599 let ceo = PrincipalRef::human_user("ceo-user").with_tenant_actor_id("actor-ceo");
2600 let org = ResourceRef::new("acme", "*", ResourceKind::Organization, "acme");
2601 let grant = ScopedGrant::new("grant-ceo-global", ceo, org, GrantSource::ExecutiveGlobal)
2602 .with_permissions(vec![
2603 AccessPermission::View,
2604 AccessPermission::Read,
2605 AccessPermission::Admin,
2606 ])
2607 .with_data_classes(vec![
2608 DataClass::Internal,
2609 DataClass::Confidential,
2610 DataClass::Restricted,
2611 DataClass::Executive,
2612 DataClass::FinancialRecord,
2613 ]);
2614
2615 assert_eq!(grant.grant_source, GrantSource::ExecutiveGlobal);
2616 assert_eq!(grant.resource.resource_kind, ResourceKind::Organization);
2617 assert_eq!(grant.resource.workspace_id, "*");
2618 assert!(grant.has_permission(AccessPermission::Admin));
2619 assert!(grant.allows_data_class(DataClass::Executive));
2620 }
2621
2622 #[test]
2623 fn scoped_grant_models_down_scoped_delegation_with_expiry() {
2624 let delegate = PrincipalRef::new(PrincipalKind::ExternalDelegate, "vendor-agent")
2625 .with_issuer_subject("a2a://vendor.example", "vendor-agent-7");
2626 let delegator = PrincipalRef::human_user("user-legal");
2627 let contract_branch =
2628 ResourceRef::new("acme", "legal", ResourceKind::Document, "vendor-contract")
2629 .with_project_id("vendor-review")
2630 .with_path_prefix("/contracts/vendor-a/");
2631 let grant = ScopedGrant::new(
2632 "grant-vendor-contract-read",
2633 delegate,
2634 contract_branch,
2635 GrantSource::Delegation,
2636 )
2637 .with_source_principal(delegator)
2638 .with_permissions(vec![AccessPermission::View, AccessPermission::Read])
2639 .with_data_classes(vec![DataClass::Confidential])
2640 .with_tool_patterns(vec!["mcp:google-drive:files.get".to_string()])
2641 .with_delegation_id("delegation-123")
2642 .with_expires_at_ms(2_000);
2643
2644 assert_eq!(grant.grant_source, GrantSource::Delegation);
2645 assert_eq!(grant.delegation_id.as_deref(), Some("delegation-123"));
2646 assert!(!grant.is_expired_at(1_999));
2647 assert!(grant.is_expired_at(2_000));
2648 assert_eq!(grant.tool_patterns, vec!["mcp:google-drive:files.get"]);
2649
2650 let encoded = serde_json::to_value(&grant).expect("serialize delegation grant");
2651 assert_eq!(encoded["principal"]["kind"], "external_delegate");
2652 assert_eq!(encoded["grant_source"], "delegation");
2653 assert_eq!(encoded["delegation_id"], "delegation-123");
2654 }
2655
2656 #[test]
2657 fn assertion_metadata_derives_from_verified_tenant_context() {
2658 let tenant = TenantContext::explicit_user_workspace(
2659 "org-a",
2660 "workspace-a",
2661 Some("deployment-a".to_string()),
2662 "user-a",
2663 );
2664 let principal = RequestPrincipal::authenticated_user("user-a", "tandem-web");
2665 let verified = VerifiedTenantContext {
2666 tenant_context: tenant,
2667 human_actor: HumanActor::tandem_user("user-a"),
2668 authority_chain: AuthorityChain::from_request(principal),
2669 roles: vec!["enterprise:admin".to_string()],
2670 org_units: Vec::new(),
2671 capabilities: Vec::new(),
2672 policy_version: None,
2673 strict_projection: None,
2674 issuer: "tandem-web".to_string(),
2675 audience: "tandem-runtime".to_string(),
2676 issued_at_ms: 1_000,
2677 expires_at_ms: 2_000,
2678 assertion_id: "assertion-123".to_string(),
2679 };
2680
2681 let metadata = AssertionMetadata::from(&verified);
2682
2683 assert_eq!(metadata.issuer, "tandem-web");
2684 assert_eq!(metadata.audience, "tandem-runtime");
2685 assert_eq!(metadata.assertion_id, "assertion-123");
2686 assert_eq!(metadata.purpose, Some(SigningKeyPurpose::ContextAssertion));
2687 assert!(!metadata.is_expired_at(1_999));
2688 assert!(metadata.is_expired_at(2_000));
2689 }
2690
2691 #[test]
2692 fn signing_key_purpose_defines_enterprise_signing_lanes() {
2693 let purposes = vec![
2694 SigningKeyPurpose::ContextAssertion,
2695 SigningKeyPurpose::ApprovalReceipt,
2696 SigningKeyPurpose::DelegationProjection,
2697 SigningKeyPurpose::A2aPeerAssertion,
2698 SigningKeyPurpose::BreakGlassAdminAssertion,
2699 ];
2700
2701 let encoded = serde_json::to_value(&purposes).expect("serialize signing key purposes");
2702
2703 assert_eq!(
2704 encoded,
2705 serde_json::json!([
2706 "context_assertion",
2707 "approval_receipt",
2708 "delegation_projection",
2709 "a2a_peer_assertion",
2710 "break_glass_admin_assertion"
2711 ])
2712 );
2713 assert_eq!(
2714 SigningKeyPurpose::parse("break-glass-admin"),
2715 Ok(SigningKeyPurpose::BreakGlassAdminAssertion)
2716 );
2717 assert!(SigningKeyPurpose::parse("arbitrary_header_key").is_err());
2718 }
2719
2720 #[test]
2721 fn data_boundary_denies_explicitly_blocked_classes() {
2722 let boundary = DataBoundary {
2723 allowed_data_classes: vec![DataClass::Internal, DataClass::Executive],
2724 denied_data_classes: vec![DataClass::Executive],
2725 };
2726
2727 assert!(boundary.allows(DataClass::Internal));
2728 assert!(!boundary.allows(DataClass::Executive));
2729 assert!(!boundary.allows(DataClass::FinancialRecord));
2730 }
2731
2732 #[test]
2733 fn strict_tenant_context_round_trips_project_scoped_agent_projection() {
2734 let tenant_context = TenantContext::explicit_user_workspace(
2735 "acme",
2736 "engineering",
2737 Some("deployment-prod".to_string()),
2738 "user-eng",
2739 );
2740 let request_principal = RequestPrincipal::authenticated_user("user-eng", "tandem-web");
2741 let authority_chain = AuthorityChain::from_request(request_principal);
2742 let agent =
2743 PrincipalRef::agent_worker("agent-platform-fix").with_tenant_actor_id("user-eng");
2744 let project = ResourceRef::new("acme", "engineering", ResourceKind::Project, "platform");
2745 let repository =
2746 ResourceRef::new("acme", "engineering", ResourceKind::Repository, "tandem")
2747 .with_project_id("platform")
2748 .with_path_prefix("crates/tandem-enterprise-contract/");
2749 let resource_scope = ResourceScope {
2750 root: project,
2751 allowed_resources: vec![repository.clone()],
2752 denied_resources: vec![ResourceRef::new(
2753 "acme",
2754 "engineering",
2755 ResourceKind::Directory,
2756 "restricted",
2757 )
2758 .with_project_id("platform")
2759 .with_path_prefix("crates/tandem-enterprise-contract/restricted/")],
2760 max_depth: Some(5),
2761 };
2762 let grant = ScopedGrant::new(
2763 "grant-agent-platform-edit",
2764 agent.clone(),
2765 repository,
2766 GrantSource::Delegation,
2767 )
2768 .with_permissions(vec![
2769 AccessPermission::View,
2770 AccessPermission::Read,
2771 AccessPermission::Edit,
2772 ])
2773 .with_data_classes(vec![DataClass::SourceCode, DataClass::Internal])
2774 .with_delegation_id("delegation-platform-fix")
2775 .with_expires_at_ms(2_000);
2776 let context = StrictTenantContext::new(
2777 tenant_context,
2778 agent,
2779 authority_chain,
2780 resource_scope,
2781 AssertionMetadata::new(
2782 "tandem-web",
2783 "tandem-runtime",
2784 1_000,
2785 2_000,
2786 "assertion-platform-fix",
2787 )
2788 .with_key_id("deployment-prod-ctx-2026-05-01")
2789 .with_purpose(SigningKeyPurpose::ContextAssertion),
2790 )
2791 .with_grants(vec![grant])
2792 .with_data_boundary(DataBoundary::allow(vec![
2793 DataClass::SourceCode,
2794 DataClass::Internal,
2795 ]));
2796
2797 assert!(context.has_permission(AccessPermission::Edit));
2798 assert!(!context.has_permission(AccessPermission::Execute));
2799 assert!(context.allows_data_class(DataClass::SourceCode));
2800 assert!(!context.allows_data_class(DataClass::Executive));
2801 assert!(!context.is_expired_at(1_999));
2802 assert!(context.is_expired_at(2_000));
2803
2804 let decoded: StrictTenantContext = serde_json::from_value(
2805 serde_json::to_value(&context).expect("serialize strict context"),
2806 )
2807 .expect("deserialize strict context");
2808 assert_eq!(decoded, context);
2809 assert_eq!(
2810 decoded.grants[0].delegation_id.as_deref(),
2811 Some("delegation-platform-fix")
2812 );
2813 assert_eq!(
2814 decoded.assertion.key_id.as_deref(),
2815 Some("deployment-prod-ctx-2026-05-01")
2816 );
2817 }
2818
2819 #[test]
2820 fn grant_evaluation_allows_department_membership_data_access() {
2821 let finance_store =
2822 ResourceRef::new("acme", "finance", ResourceKind::DataStore, "finance-ledger");
2823 let principal = PrincipalRef::human_user("user-finance");
2824 let grant = ScopedGrant::new(
2825 "grant-finance-read",
2826 principal.clone(),
2827 ResourceRef::new("acme", "finance", ResourceKind::Department, "finance"),
2828 GrantSource::DepartmentMembership,
2829 )
2830 .with_permissions(vec![AccessPermission::View, AccessPermission::Read])
2831 .with_data_classes(vec![DataClass::FinancialRecord]);
2832 let context = test_strict_context(
2833 "finance",
2834 principal,
2835 ResourceScope::root(ResourceRef::new(
2836 "acme",
2837 "finance",
2838 ResourceKind::Department,
2839 "finance",
2840 )),
2841 vec![grant],
2842 );
2843
2844 let evaluation = context.evaluate_access(
2845 &finance_store,
2846 AccessPermission::Read,
2847 DataClass::FinancialRecord,
2848 1_500,
2849 );
2850
2851 assert_eq!(evaluation.decision, AccessDecision::Allow);
2852 assert_eq!(evaluation.grant_id.as_deref(), Some("grant-finance-read"));
2853 }
2854
2855 #[test]
2856 fn grant_evaluation_deny_wins_over_org_wide_allow() {
2857 let hr_document =
2858 ResourceRef::new("acme", "hr", ResourceKind::Document, "compensation-plan");
2859 let principal = PrincipalRef::human_user("user-exec");
2860 let org_allow = ScopedGrant::new(
2861 "grant-org-read",
2862 principal.clone(),
2863 ResourceRef::new("acme", "*", ResourceKind::Organization, "acme"),
2864 GrantSource::ExecutiveGlobal,
2865 )
2866 .with_permissions(vec![AccessPermission::Read])
2867 .with_data_classes(vec![DataClass::Executive]);
2868 let hr_deny = ScopedGrant::new(
2869 "deny-hr-comp",
2870 principal.clone(),
2871 ResourceRef::new("acme", "hr", ResourceKind::Document, "compensation-plan"),
2872 GrantSource::Direct,
2873 )
2874 .with_effect(AccessEffect::Deny)
2875 .with_permissions(vec![AccessPermission::Read])
2876 .with_data_classes(vec![DataClass::Executive]);
2877 let context = test_strict_context(
2878 "*",
2879 principal,
2880 ResourceScope::root(ResourceRef::new(
2881 "acme",
2882 "*",
2883 ResourceKind::Organization,
2884 "acme",
2885 )),
2886 vec![org_allow, hr_deny],
2887 )
2888 .with_data_boundary(DataBoundary::allow(vec![DataClass::Executive]));
2889
2890 let evaluation = context.evaluate_access(
2891 &hr_document,
2892 AccessPermission::Read,
2893 DataClass::Executive,
2894 1_500,
2895 );
2896
2897 assert_eq!(evaluation.decision, AccessDecision::Deny);
2898 assert_eq!(evaluation.grant_id.as_deref(), Some("deny-hr-comp"));
2899 assert_eq!(evaluation.reason, "matching_deny_grant");
2900 }
2901
2902 #[test]
2903 fn grant_evaluation_project_grant_applies_to_file_path() {
2904 let principal = PrincipalRef::agent_worker("agent-platform");
2905 let file = ResourceRef::new(
2906 "acme",
2907 "engineering",
2908 ResourceKind::File,
2909 "crates/tandem-enterprise-contract/src/lib.rs",
2910 )
2911 .with_project_id("platform")
2912 .with_path_prefix("crates/tandem-enterprise-contract/src/lib.rs");
2913 let grant = ScopedGrant::new(
2914 "grant-platform-source-edit",
2915 principal.clone(),
2916 ResourceRef::new("acme", "engineering", ResourceKind::Project, "platform"),
2917 GrantSource::Delegation,
2918 )
2919 .with_permissions(vec![AccessPermission::Read, AccessPermission::Edit])
2920 .with_data_classes(vec![DataClass::SourceCode]);
2921 let context = test_strict_context(
2922 "engineering",
2923 principal,
2924 ResourceScope::root(ResourceRef::new(
2925 "acme",
2926 "engineering",
2927 ResourceKind::Project,
2928 "platform",
2929 )),
2930 vec![grant],
2931 );
2932
2933 let evaluation =
2934 context.evaluate_access(&file, AccessPermission::Edit, DataClass::SourceCode, 1_500);
2935
2936 assert_eq!(evaluation.decision, AccessDecision::Allow);
2937 assert_eq!(
2938 evaluation.grant_id.as_deref(),
2939 Some("grant-platform-source-edit")
2940 );
2941 }
2942
2943 #[test]
2944 fn grant_evaluation_expired_grant_does_not_apply() {
2945 let principal = PrincipalRef::human_user("user-finance");
2946 let finance_store =
2947 ResourceRef::new("acme", "finance", ResourceKind::DataStore, "finance-ledger");
2948 let grant = ScopedGrant::new(
2949 "grant-expired-finance",
2950 principal.clone(),
2951 finance_store.clone(),
2952 GrantSource::Direct,
2953 )
2954 .with_permissions(vec![AccessPermission::Read])
2955 .with_data_classes(vec![DataClass::FinancialRecord])
2956 .with_expires_at_ms(1_400);
2957 let context = test_strict_context(
2958 "finance",
2959 principal,
2960 ResourceScope::root(ResourceRef::new(
2961 "acme",
2962 "finance",
2963 ResourceKind::Workspace,
2964 "finance",
2965 )),
2966 vec![grant],
2967 );
2968
2969 let evaluation = context.evaluate_access(
2970 &finance_store,
2971 AccessPermission::Read,
2972 DataClass::FinancialRecord,
2973 1_500,
2974 );
2975
2976 assert_eq!(evaluation.decision, AccessDecision::NotApplicable);
2977 assert_eq!(evaluation.reason, "no_matching_allow_grant");
2978 }
2979
2980 #[test]
2981 fn grant_evaluation_delegated_grant_stays_narrower_than_parent_scope() {
2982 let principal = PrincipalRef::new(PrincipalKind::ExternalDelegate, "vendor-agent");
2983 let allowed_doc =
2984 ResourceRef::new("acme", "legal", ResourceKind::Document, "vendor-contract")
2985 .with_project_id("vendor-review")
2986 .with_path_prefix("/contracts/vendor-a/");
2987 let other_doc = ResourceRef::new("acme", "legal", ResourceKind::Document, "board-minutes")
2988 .with_project_id("vendor-review")
2989 .with_path_prefix("/executive/board-minutes/");
2990 let grant = ScopedGrant::new(
2991 "grant-vendor-contract",
2992 principal.clone(),
2993 allowed_doc.clone(),
2994 GrantSource::Delegation,
2995 )
2996 .with_permissions(vec![AccessPermission::Read])
2997 .with_data_classes(vec![DataClass::Confidential])
2998 .with_delegation_id("delegation-123");
2999 let context = test_strict_context(
3000 "legal",
3001 principal,
3002 ResourceScope {
3003 root: ResourceRef::new("acme", "legal", ResourceKind::Project, "vendor-review"),
3004 allowed_resources: vec![allowed_doc.clone()],
3005 denied_resources: vec![ResourceRef::new(
3006 "acme",
3007 "legal",
3008 ResourceKind::Document,
3009 "board-minutes",
3010 )],
3011 max_depth: Some(2),
3012 },
3013 vec![grant],
3014 );
3015
3016 let allowed = context.evaluate_access(
3017 &allowed_doc,
3018 AccessPermission::Read,
3019 DataClass::Confidential,
3020 1_500,
3021 );
3022 let denied = context.evaluate_access(
3023 &other_doc,
3024 AccessPermission::Read,
3025 DataClass::Confidential,
3026 1_500,
3027 );
3028
3029 assert_eq!(allowed.decision, AccessDecision::Allow);
3030 assert_eq!(denied.decision, AccessDecision::Deny);
3031 assert_eq!(denied.reason, "resource_explicitly_denied_by_scope");
3032 }
3033
3034 #[test]
3035 fn department_grants_do_not_cross_resource_or_data_class_boundaries() {
3036 let finance_user = PrincipalRef::human_user("user-finance");
3037 let finance_grant = ScopedGrant::new(
3038 "grant-finance-ledger-read",
3039 finance_user.clone(),
3040 ResourceRef::new("acme", "finance", ResourceKind::Department, "finance"),
3041 GrantSource::DepartmentMembership,
3042 )
3043 .with_permissions(vec![AccessPermission::Read])
3044 .with_data_classes(vec![DataClass::FinancialRecord]);
3045 let finance_context = test_strict_context(
3046 "finance",
3047 finance_user,
3048 ResourceScope::root(ResourceRef::new(
3049 "acme",
3050 "finance",
3051 ResourceKind::Department,
3052 "finance",
3053 )),
3054 vec![finance_grant],
3055 );
3056 let engineering_repo = ResourceRef::new(
3057 "acme",
3058 "engineering",
3059 ResourceKind::Repository,
3060 "product-api",
3061 );
3062 let finance_denied_engineering = finance_context.evaluate_access(
3063 &engineering_repo,
3064 AccessPermission::Read,
3065 DataClass::SourceCode,
3066 1_500,
3067 );
3068
3069 assert_eq!(
3070 finance_denied_engineering.decision,
3071 AccessDecision::NotApplicable
3072 );
3073 assert_eq!(
3074 finance_denied_engineering.reason,
3075 "resource_outside_projected_scope"
3076 );
3077
3078 let engineering_user = PrincipalRef::human_user("user-engineering");
3079 let engineering_grant = ScopedGrant::new(
3080 "grant-engineering-source-read",
3081 engineering_user.clone(),
3082 ResourceRef::new("acme", "engineering", ResourceKind::Project, "product-api"),
3083 GrantSource::DepartmentMembership,
3084 )
3085 .with_permissions(vec![AccessPermission::Read])
3086 .with_data_classes(vec![DataClass::SourceCode]);
3087 let engineering_context = test_strict_context(
3088 "engineering",
3089 engineering_user,
3090 ResourceScope::root(ResourceRef::new(
3091 "acme",
3092 "engineering",
3093 ResourceKind::Project,
3094 "product-api",
3095 )),
3096 vec![engineering_grant],
3097 );
3098 let hr_compensation =
3099 ResourceRef::new("acme", "hr", ResourceKind::Document, "compensation-bands");
3100 let engineering_denied_hr = engineering_context.evaluate_access(
3101 &hr_compensation,
3102 AccessPermission::Read,
3103 DataClass::FinancialRecord,
3104 1_500,
3105 );
3106
3107 assert_eq!(
3108 engineering_denied_hr.decision,
3109 AccessDecision::NotApplicable
3110 );
3111 assert_eq!(
3112 engineering_denied_hr.reason,
3113 "resource_outside_projected_scope"
3114 );
3115 }
3116
3117 #[test]
3118 fn executive_global_access_is_explicit_and_not_inherited_by_agents() {
3119 let ceo = PrincipalRef::human_user("ceo-user");
3120 let executive_grant = ScopedGrant::new(
3121 "grant-ceo-org-read",
3122 ceo.clone(),
3123 ResourceRef::new("acme", "*", ResourceKind::Organization, "acme"),
3124 GrantSource::ExecutiveGlobal,
3125 )
3126 .with_permissions(vec![AccessPermission::Read])
3127 .with_data_classes(vec![
3128 DataClass::Executive,
3129 DataClass::FinancialRecord,
3130 DataClass::SourceCode,
3131 ]);
3132 let ceo_context = test_strict_context(
3133 "*",
3134 ceo,
3135 ResourceScope::root(ResourceRef::new(
3136 "acme",
3137 "*",
3138 ResourceKind::Organization,
3139 "acme",
3140 )),
3141 vec![executive_grant],
3142 );
3143 let hr_compensation =
3144 ResourceRef::new("acme", "hr", ResourceKind::Document, "compensation-bands");
3145 let engineering_repo = ResourceRef::new(
3146 "acme",
3147 "engineering",
3148 ResourceKind::Repository,
3149 "product-api",
3150 );
3151
3152 assert_eq!(
3153 ceo_context
3154 .evaluate_access(
3155 &hr_compensation,
3156 AccessPermission::Read,
3157 DataClass::FinancialRecord,
3158 1_500,
3159 )
3160 .decision,
3161 AccessDecision::Allow
3162 );
3163 assert_eq!(
3164 ceo_context
3165 .evaluate_access(
3166 &engineering_repo,
3167 AccessPermission::Read,
3168 DataClass::SourceCode,
3169 1_500,
3170 )
3171 .decision,
3172 AccessDecision::Allow
3173 );
3174
3175 let ceo_agent =
3176 PrincipalRef::agent_worker("agent-ceo-summary").with_tenant_actor_id("ceo-user");
3177 let narrow_agent_grant = ScopedGrant::new(
3178 "grant-agent-product-read",
3179 ceo_agent.clone(),
3180 ResourceRef::new("acme", "engineering", ResourceKind::Project, "product-api"),
3181 GrantSource::Delegation,
3182 )
3183 .with_source_principal(PrincipalRef::human_user("ceo-user"))
3184 .with_permissions(vec![AccessPermission::Read])
3185 .with_data_classes(vec![DataClass::SourceCode]);
3186 let agent_context = test_strict_context(
3187 "engineering",
3188 ceo_agent.clone(),
3189 ResourceScope::root(ResourceRef::new(
3190 "acme",
3191 "engineering",
3192 ResourceKind::Project,
3193 "product-api",
3194 )),
3195 vec![narrow_agent_grant],
3196 );
3197
3198 let agent_denied_hr = agent_context.evaluate_access(
3199 &hr_compensation,
3200 AccessPermission::Read,
3201 DataClass::FinancialRecord,
3202 1_500,
3203 );
3204 assert_eq!(agent_denied_hr.decision, AccessDecision::NotApplicable);
3205 assert_eq!(agent_denied_hr.reason, "resource_outside_projected_scope");
3206
3207 let projected_agent_grant = ScopedGrant::new(
3208 "grant-agent-executive-projection",
3209 ceo_agent.clone(),
3210 ResourceRef::new("acme", "*", ResourceKind::Organization, "acme"),
3211 GrantSource::Delegation,
3212 )
3213 .with_source_principal(PrincipalRef::human_user("ceo-user"))
3214 .with_permissions(vec![AccessPermission::Read])
3215 .with_data_classes(vec![DataClass::FinancialRecord])
3216 .with_delegation_id("delegation-ceo-summary");
3217 let projected_agent_context = test_strict_context(
3218 "*",
3219 ceo_agent,
3220 ResourceScope::root(ResourceRef::new(
3221 "acme",
3222 "*",
3223 ResourceKind::Organization,
3224 "acme",
3225 )),
3226 vec![projected_agent_grant],
3227 );
3228
3229 assert_eq!(
3230 projected_agent_context
3231 .evaluate_access(
3232 &hr_compensation,
3233 AccessPermission::Read,
3234 DataClass::FinancialRecord,
3235 1_500,
3236 )
3237 .decision,
3238 AccessDecision::Allow
3239 );
3240 }
3241
3242 #[test]
3243 fn connector_credential_ref_defaults_to_read_only_secret_reference() {
3244 let tenant = TenantContext::explicit_user_workspace(
3245 "acme",
3246 "finance",
3247 Some("deployment-prod".to_string()),
3248 "user-admin",
3249 );
3250 let credential = ConnectorCredentialRef::read_only(
3251 "acme",
3252 "finance",
3253 "google-drive-finance",
3254 "credential-readonly",
3255 SecretRef {
3256 org_id: "acme".to_string(),
3257 workspace_id: "finance".to_string(),
3258 provider: "google_kms".to_string(),
3259 secret_id: "secret://connectors/google-drive-finance/read".to_string(),
3260 name: "Google Drive read token".to_string(),
3261 },
3262 1_000,
3263 )
3264 .with_source_bound_resource(ResourceRef::new(
3265 "acme",
3266 "finance",
3267 ResourceKind::SharedDrive,
3268 "finance-drive",
3269 ));
3270
3271 assert_eq!(
3272 credential.credential_class,
3273 ConnectorCredentialClass::ReadOnly
3274 );
3275 assert!(credential.validate_for_tenant(&tenant).is_ok());
3276
3277 let encoded = serde_json::to_value(&credential).expect("serialize credential ref");
3278 assert_eq!(encoded["credential_class"], "read_only");
3279 assert_eq!(
3280 encoded["secret_ref"]["secret_id"],
3281 credential.secret_ref.secret_id
3282 );
3283 assert!(encoded.get("credential_value").is_none());
3284 assert!(encoded.get("access_token").is_none());
3285 assert_eq!(
3286 encoded["source_bound_resource"]["resource_kind"],
3287 "shared_drive"
3288 );
3289
3290 let wrong_tenant = TenantContext::explicit_user_workspace(
3291 "acme",
3292 "engineering",
3293 Some("deployment-prod".to_string()),
3294 "user-admin",
3295 );
3296 assert!(matches!(
3297 credential.validate_for_tenant(&wrong_tenant),
3298 Err(SecretRefError::WorkspaceMismatch)
3299 ));
3300 }
3301
3302 #[test]
3303 fn source_binding_blocks_ingestion_when_connector_or_binding_is_not_active() {
3304 let tenant = TenantContext::explicit_user_workspace(
3305 "acme",
3306 "finance",
3307 Some("deployment-prod".to_string()),
3308 "user-admin",
3309 );
3310 let admin = PrincipalRef::human_user("user-admin");
3311 let connector = ConnectorInstance::active(
3312 "google-drive-finance",
3313 tenant.clone(),
3314 "google_drive",
3315 admin.clone(),
3316 1_000,
3317 );
3318 let binding = SourceBinding::enabled(
3319 "binding-finance-drive",
3320 tenant.clone(),
3321 "google-drive-finance",
3322 "google_drive_shared_drive",
3323 "drive-finance",
3324 ResourceRef::new("acme", "finance", ResourceKind::DataStore, "finance-docs"),
3325 DataClass::FinancialRecord,
3326 admin,
3327 1_000,
3328 );
3329
3330 assert!(binding.can_ingest_with(&connector));
3331
3332 let paused_connector = connector
3333 .clone()
3334 .with_state(ConnectorLifecycleState::Paused, 1_100);
3335 assert!(!binding.can_ingest_with(&paused_connector));
3336
3337 let revoked_connector = connector
3338 .clone()
3339 .with_state(ConnectorLifecycleState::Revoked, 1_200);
3340 assert!(!binding.can_ingest_with(&revoked_connector));
3341
3342 let quarantined_connector = connector
3343 .clone()
3344 .with_state(ConnectorLifecycleState::Quarantined, 1_300);
3345 assert!(!binding.can_ingest_with(&quarantined_connector));
3346
3347 let disabled_binding = binding
3348 .clone()
3349 .with_state(SourceBindingState::Disabled, 1_400);
3350 assert!(!disabled_binding.can_ingest_with(&connector));
3351
3352 let review_only_binding = binding.with_ingestion_policy(IngestionPolicy {
3353 allow_indexing: false,
3354 allow_prompt_context: false,
3355 require_review: true,
3356 max_depth: Some(2),
3357 });
3358 assert!(!review_only_binding.can_ingest_with(&connector));
3359 }
3360
3361 #[test]
3362 fn source_objects_and_memory_chunks_carry_resource_and_data_class_scope() {
3363 let tenant = TenantContext::explicit_user_workspace(
3364 "acme",
3365 "finance",
3366 Some("deployment-prod".to_string()),
3367 "user-admin",
3368 );
3369 let resource = ResourceRef::new("acme", "finance", ResourceKind::Document, "board-report")
3370 .with_parent_path(vec![ResourcePathSegment::new(
3371 ResourceKind::SharedDrive,
3372 "finance-drive",
3373 )]);
3374 let object = SourceObject {
3375 source_object_id: "source-object-1".to_string(),
3376 tenant_context: tenant.clone(),
3377 binding_id: "binding-finance-drive".to_string(),
3378 connector_id: "google-drive-finance".to_string(),
3379 native_object_id: "drive-file-123".to_string(),
3380 resource_ref: resource.clone(),
3381 data_class: DataClass::FinancialRecord,
3382 lifecycle_state: SourceObjectLifecycleState::Active,
3383 native_object_path: Some("/finance/board-report.md".to_string()),
3384 content_hash: Some("content-sha256:abc".to_string()),
3385 source_hash: Some("sha256:abc".to_string()),
3386 parent_source_object_id: None,
3387 created_at_ms: 1_000,
3388 updated_at_ms: 1_000,
3389 last_seen_at_ms: Some(1_000),
3390 lifecycle_changed_at_ms: None,
3391 superseded_by_source_object_id: None,
3392 };
3393 let chunk = ScopedMemoryChunkRef {
3394 chunk_id: "chunk-1".to_string(),
3395 tenant_context: tenant.clone(),
3396 source_object_id: object.source_object_id.clone(),
3397 resource_ref: resource,
3398 data_class: object.data_class,
3399 source_hash: object.source_hash.clone(),
3400 };
3401
3402 assert!(object.dedupe_scope_key().contains("acme:finance"));
3403 assert!(object.dedupe_scope_key().contains("binding-finance-drive"));
3404 assert!(object.tenant_matches(&tenant));
3405 assert!(object.is_active());
3406 assert!(object.allows_prompt_context());
3407 assert!(object
3408 .lifecycle_identity_key()
3409 .contains("binding-finance-drive"));
3410 assert!(!object
3411 .clone()
3412 .with_lifecycle_state(SourceObjectLifecycleState::Tombstoned, 2_000)
3413 .allows_prompt_context());
3414 assert_eq!(chunk.tenant_context, tenant);
3415 assert_eq!(chunk.source_object_id, "source-object-1");
3416 assert_eq!(chunk.data_class, DataClass::FinancialRecord);
3417
3418 let encoded = serde_json::to_value(&chunk).expect("serialize memory chunk ref");
3419 assert_eq!(encoded["source_object_id"], "source-object-1");
3420 assert_eq!(encoded["resource_ref"]["resource_kind"], "document");
3421 assert_eq!(encoded["data_class"], "financial_record");
3422 }
3423
3424 #[test]
3425 fn ingestion_quarantine_tracks_review_without_making_output_searchable() {
3426 let tenant = TenantContext::explicit_user_workspace(
3427 "acme",
3428 "legal",
3429 Some("deployment-prod".to_string()),
3430 "user-legal",
3431 );
3432 let quarantine = IngestionQuarantine {
3433 quarantine_id: "quarantine-1".to_string(),
3434 tenant_context: tenant,
3435 connector_id: "notion-legal".to_string(),
3436 binding_id: "binding-legal-notion".to_string(),
3437 source_object_ids: vec!["source-object-legal-1".to_string()],
3438 reason: "high_risk_data_class_requires_review".to_string(),
3439 created_at_ms: 1_000,
3440 reviewed_by: Some(PrincipalRef::human_user("legal-admin")),
3441 reviewed_at_ms: Some(1_500),
3442 disposition: Some(QuarantineDisposition::Delete),
3443 };
3444 let job = IngestionJob {
3445 job_id: "ingestion-job-1".to_string(),
3446 tenant_context: quarantine.tenant_context.clone(),
3447 connector_id: quarantine.connector_id.clone(),
3448 binding_id: quarantine.binding_id.clone(),
3449 state: IngestionJobState::Quarantined,
3450 source_object_ids: quarantine.source_object_ids.clone(),
3451 started_at_ms: Some(900),
3452 finished_at_ms: Some(1_000),
3453 quarantine_id: Some(quarantine.quarantine_id.clone()),
3454 };
3455
3456 assert_eq!(job.state, IngestionJobState::Quarantined);
3457 assert_eq!(job.quarantine_id.as_deref(), Some("quarantine-1"));
3458 assert_eq!(quarantine.disposition, Some(QuarantineDisposition::Delete));
3459
3460 let encoded = serde_json::to_value(&quarantine).expect("serialize quarantine");
3461 assert_eq!(encoded["disposition"], "delete");
3462 assert_eq!(encoded["reason"], "high_risk_data_class_requires_review");
3463 }
3464
3465 fn test_strict_context(
3466 workspace_id: &str,
3467 principal: PrincipalRef,
3468 resource_scope: ResourceScope,
3469 grants: Vec<ScopedGrant>,
3470 ) -> StrictTenantContext {
3471 StrictTenantContext::new(
3472 TenantContext::explicit_user_workspace(
3473 "acme",
3474 workspace_id,
3475 Some("deployment-test".to_string()),
3476 principal.id.clone(),
3477 ),
3478 principal,
3479 AuthorityChain::from_request(RequestPrincipal::authenticated_user(
3480 "user-test",
3481 "tandem-web",
3482 )),
3483 resource_scope,
3484 AssertionMetadata::new(
3485 "tandem-web",
3486 "tandem-runtime",
3487 1_000,
3488 2_000,
3489 "assertion-test",
3490 ),
3491 )
3492 .with_grants(grants)
3493 .with_data_boundary(DataBoundary::allow(vec![
3494 DataClass::Internal,
3495 DataClass::Confidential,
3496 DataClass::Executive,
3497 DataClass::FinancialRecord,
3498 DataClass::SourceCode,
3499 ]))
3500 }
3501}