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}