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