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}