Skip to main content

tandem_enterprise_contract/
lib.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "snake_case")]
5pub enum EnterpriseMode {
6    Disabled,
7    Optional,
8    Required,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum EnterpriseBridgeState {
14    Absent,
15    Noop,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum EnterpriseCapability {
21    Status,
22    TenantContext,
23    NoopBridge,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum TenantSource {
29    #[default]
30    LocalImplicit,
31    Explicit,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RequestPrincipal {
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub actor_id: Option<String>,
38    #[serde(default)]
39    pub source: String,
40}
41
42impl RequestPrincipal {
43    pub fn anonymous() -> Self {
44        Self {
45            actor_id: None,
46            source: "anonymous".to_string(),
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct AutomationPrincipal {
53    pub automation_id: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub owner_id: Option<String>,
56    #[serde(default)]
57    pub source: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum ExecutionPrincipal {
63    Request(RequestPrincipal),
64    Automation(AutomationPrincipal),
65    ServiceAccount {
66        service_account_id: String,
67    },
68    #[default]
69    Unknown,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct AuthorityChain {
74    pub initiated_by: RequestPrincipal,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub owned_by: Option<AutomationPrincipal>,
77    pub executed_as: ExecutionPrincipal,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub approved_by: Option<RequestPrincipal>,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
83pub struct LocalImplicitTenant;
84
85impl LocalImplicitTenant {
86    pub const ORG_ID: &'static str = "local";
87    pub const WORKSPACE_ID: &'static str = "local";
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct TenantContext {
92    pub org_id: String,
93    pub workspace_id: String,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub actor_id: Option<String>,
96    #[serde(default)]
97    pub source: TenantSource,
98}
99
100impl Default for TenantContext {
101    fn default() -> Self {
102        Self::local_implicit()
103    }
104}
105
106impl TenantContext {
107    pub fn local_implicit() -> Self {
108        Self {
109            org_id: LocalImplicitTenant::ORG_ID.to_string(),
110            workspace_id: LocalImplicitTenant::WORKSPACE_ID.to_string(),
111            actor_id: None,
112            source: TenantSource::LocalImplicit,
113        }
114    }
115
116    pub fn explicit(
117        org_id: impl Into<String>,
118        workspace_id: impl Into<String>,
119        actor_id: Option<String>,
120    ) -> Self {
121        Self {
122            org_id: org_id.into(),
123            workspace_id: workspace_id.into(),
124            actor_id,
125            source: TenantSource::Explicit,
126        }
127    }
128
129    pub fn is_local_implicit(&self) -> bool {
130        self.source == TenantSource::LocalImplicit
131            && self.org_id == LocalImplicitTenant::ORG_ID
132            && self.workspace_id == LocalImplicitTenant::WORKSPACE_ID
133            && self.actor_id.is_none()
134    }
135}
136
137impl From<LocalImplicitTenant> for TenantContext {
138    fn from(_: LocalImplicitTenant) -> Self {
139        Self::local_implicit()
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct SecretRef {
145    pub org_id: String,
146    pub workspace_id: String,
147    pub provider: String,
148    pub secret_id: String,
149    pub name: String,
150}
151
152impl SecretRef {
153    pub fn validate_for_tenant(&self, ctx: &TenantContext) -> Result<(), SecretRefError> {
154        if self.org_id != ctx.org_id {
155            return Err(SecretRefError::OrgMismatch);
156        }
157        if self.workspace_id != ctx.workspace_id {
158            return Err(SecretRefError::WorkspaceMismatch);
159        }
160        Ok(())
161    }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum SecretRefError {
166    OrgMismatch,
167    WorkspaceMismatch,
168    NotFound,
169}
170
171impl core::fmt::Display for SecretRefError {
172    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
173        match self {
174            Self::OrgMismatch => write!(f, "secret org does not match request context"),
175            Self::WorkspaceMismatch => write!(f, "secret workspace does not match request context"),
176            Self::NotFound => write!(f, "secret not found"),
177        }
178    }
179}
180
181impl std::error::Error for SecretRefError {}
182
183pub trait TenantContextResolver: Send + Sync {
184    fn resolve_tenant_context(
185        &self,
186        org_id: Option<&str>,
187        workspace_id: Option<&str>,
188        actor_id: Option<&str>,
189    ) -> TenantContext;
190}
191
192#[derive(Debug, Default, Clone, Copy)]
193pub struct HeaderTenantContextResolver;
194
195impl TenantContextResolver for HeaderTenantContextResolver {
196    fn resolve_tenant_context(
197        &self,
198        org_id: Option<&str>,
199        workspace_id: Option<&str>,
200        actor_id: Option<&str>,
201    ) -> TenantContext {
202        let org_id = org_id
203            .map(str::trim)
204            .filter(|value| !value.is_empty())
205            .unwrap_or(LocalImplicitTenant::ORG_ID);
206        let workspace_id = workspace_id
207            .map(str::trim)
208            .filter(|value| !value.is_empty())
209            .unwrap_or(LocalImplicitTenant::WORKSPACE_ID);
210        let actor_id = actor_id
211            .map(str::trim)
212            .filter(|value| !value.is_empty())
213            .map(ToString::to_string);
214
215        if org_id == LocalImplicitTenant::ORG_ID
216            && workspace_id == LocalImplicitTenant::WORKSPACE_ID
217            && actor_id.is_none()
218        {
219            TenantContext::local_implicit()
220        } else {
221            TenantContext::explicit(org_id.to_string(), workspace_id.to_string(), actor_id)
222        }
223    }
224}
225
226pub trait RequestAuthorizationHook: Send + Sync {
227    fn authorize(&self, principal: &RequestPrincipal, tenant: &TenantContext) -> bool;
228}
229
230#[derive(Debug, Default, Clone, Copy)]
231pub struct NoopRequestAuthorizationHook;
232
233impl RequestAuthorizationHook for NoopRequestAuthorizationHook {
234    fn authorize(&self, _principal: &RequestPrincipal, _tenant: &TenantContext) -> bool {
235        true
236    }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct EnterpriseStatus {
241    pub mode: EnterpriseMode,
242    pub bridge_state: EnterpriseBridgeState,
243    #[serde(default)]
244    pub capabilities: Vec<EnterpriseCapability>,
245    pub tenant_context: TenantContext,
246    pub public_build: bool,
247    pub contract_version: String,
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub notes: Vec<String>,
250}
251
252impl EnterpriseStatus {
253    pub fn public_oss() -> Self {
254        Self {
255            mode: EnterpriseMode::Disabled,
256            bridge_state: EnterpriseBridgeState::Absent,
257            capabilities: vec![
258                EnterpriseCapability::Status,
259                EnterpriseCapability::TenantContext,
260            ],
261            tenant_context: TenantContext::local_implicit(),
262            public_build: true,
263            contract_version: "v1".to_string(),
264            notes: vec![
265                "enterprise bridge is not configured".to_string(),
266                "OSS mode uses a local implicit tenant until enterprise mode is enabled"
267                    .to_string(),
268            ],
269        }
270    }
271}
272
273pub trait EnterpriseBridge: Send + Sync {
274    fn status(&self) -> EnterpriseStatus;
275}
276
277#[derive(Debug, Default, Clone, Copy)]
278pub struct NoopEnterpriseBridge;
279
280impl EnterpriseBridge for NoopEnterpriseBridge {
281    fn status(&self) -> EnterpriseStatus {
282        EnterpriseStatus::public_oss()
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn secret_ref_validation_rejects_cross_tenant_access() {
292        let secret_ref = SecretRef {
293            org_id: "org-a".to_string(),
294            workspace_id: "workspace-a".to_string(),
295            provider: "mcp_header".to_string(),
296            secret_id: "secret-a".to_string(),
297            name: "authorization".to_string(),
298        };
299        let tenant = TenantContext::explicit("org-a", "workspace-a", None);
300        assert!(secret_ref.validate_for_tenant(&tenant).is_ok());
301
302        let wrong_workspace = TenantContext::explicit("org-a", "workspace-b", None);
303        assert!(matches!(
304            secret_ref.validate_for_tenant(&wrong_workspace),
305            Err(SecretRefError::WorkspaceMismatch)
306        ));
307    }
308
309    #[test]
310    fn header_resolver_defaults_to_local_tenant() {
311        let resolver = HeaderTenantContextResolver;
312        let tenant = resolver.resolve_tenant_context(None, None, None);
313        assert!(tenant.is_local_implicit());
314    }
315
316    #[test]
317    fn request_authorization_hook_is_noop_by_default() {
318        let hook = NoopRequestAuthorizationHook;
319        let principal = RequestPrincipal::anonymous();
320        let tenant = TenantContext::local_implicit();
321        assert!(hook.authorize(&principal, &tenant));
322    }
323}