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