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}