Skip to main content

tandem_enterprise_contract/
lib.rs

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}