soma_domain/authz.rs
1//! Provider-dispatch authorization: safety-class → scope affinity, caller
2//! trust discipline, and structured security decisions.
3//!
4//! Modeled on axon's `axon-authz` execution-affinity layer, adapted to
5//! Soma's provider kinds and its stricter write-satisfies-read scope rule
6//! (`crate::scopes::scopes_satisfy`). This layer governs **dynamic provider
7//! execution only** — built-in actions keep their `ACTION_SPECS` scope
8//! checks in the MCP server layer.
9//!
10//! Three invariants this module enforces by construction:
11//!
12//! 1. **Affinity**: every classified dispatch target has a minimum scope
13//! derived from what its handler *can do* (execute code, egress to the
14//! network, …), independent of the scope the tool manifest declares.
15//! 2. **Trusted-local discipline**: exactly one constructor
16//! ([`CallerContext::trusted_local_caller`]) can set `trusted_local`;
17//! the remote/scoped constructor hard-codes it to `false`, so a network
18//! caller can never claim local trust.
19//! 3. **Deny-by-default**: an unclassified target denies with a stable
20//! machine-readable reason instead of falling through to "allow".
21
22use crate::scopes::{scopes_satisfy, WRITE_SCOPE};
23
24/// Stable machine-readable decision reasons. These are API: never rename an
25/// existing constant's value, only add new ones.
26pub mod reasons {
27 /// Allowed because the caller is a trusted-local caller (affinity bypassed).
28 pub const AUTHORIZED_TRUSTED_LOCAL: &str = "authorized.trusted_local";
29 /// Allowed because the caller holds the required affinity scope.
30 pub const AUTHORIZED_SCOPE_SATISFIED: &str = "authorized.scope_satisfied";
31 /// Allowed because the target's safety class requires no affinity scope.
32 pub const AUTHORIZED_NO_AFFINITY_REQUIRED: &str = "authorized.no_affinity_required";
33 /// Denied because the caller lacks the required affinity scope.
34 pub const DENIED_SCOPE_MISSING: &str = "denied.scope_missing";
35 /// Denied because inline execution of this class additionally requires local trust.
36 pub const DENIED_AFFINITY_REQUIRES_LOCAL_TRUST: &str = "denied.affinity_requires_local_trust";
37 /// Denied because the dispatch target could not be classified.
38 pub const DENIED_UNCLASSIFIED_TARGET: &str = "denied.unclassified_target";
39}
40
41/// What a dynamic provider's handler is capable of doing when invoked,
42/// classified from the provider manifest `kind`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SafetyClass {
45 /// Trusted Rust compiled into this binary (`static-rust`).
46 InProcessTrusted,
47 /// Guest code executed inside a sandboxed runtime (`wasm`).
48 SandboxedExecution,
49 /// Handlers executed by a local script runtime with host access
50 /// (`ai-sdk` TypeScript, `python`, `langchain`, `llamaindex`).
51 LocalRuntimeExecution,
52 /// Handlers whose execution is a network call to an upstream service
53 /// (`mcp`, `openapi`).
54 NetworkEgress,
55}
56
57impl SafetyClass {
58 /// Classifies a provider manifest kind string. Unknown kinds return
59 /// `None`, which [`authorize`] denies with
60 /// [`reasons::DENIED_UNCLASSIFIED_TARGET`].
61 pub fn classify_provider_kind(kind: &str) -> Option<Self> {
62 match kind {
63 "static-rust" => Some(Self::InProcessTrusted),
64 "wasm" => Some(Self::SandboxedExecution),
65 "ai-sdk" | "python" | "langchain" | "llamaindex" => Some(Self::LocalRuntimeExecution),
66 "mcp" | "openapi" => Some(Self::NetworkEgress),
67 _ => None,
68 }
69 }
70
71 /// Stable kebab-case identifier for this class, for logging and warnings.
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::InProcessTrusted => "in-process-trusted",
75 Self::SandboxedExecution => "sandboxed-execution",
76 Self::LocalRuntimeExecution => "local-runtime-execution",
77 Self::NetworkEgress => "network-egress",
78 }
79 }
80
81 /// Whether inline execution of this class additionally requires a
82 /// trusted-local caller even when the affinity scope is held.
83 pub fn requires_local_trust_when_inline(self) -> bool {
84 matches!(self, Self::LocalRuntimeExecution)
85 }
86}
87
88/// Minimum scope a caller must hold to execute a target of this class,
89/// regardless of the scope declared on the individual tool. The tool's own
90/// declared scope is still enforced separately on top of this floor.
91///
92/// `InProcessTrusted` has no floor (`None`): static Rust compiled into this
93/// binary is trusted code whose manifest scopes govern entirely — this is
94/// what keeps public tools (e.g. `help`, which declares no scope) reachable
95/// by unauthenticated callers.
96pub fn required_scope_for_safety_class(class: SafetyClass) -> Option<&'static str> {
97 match class {
98 SafetyClass::InProcessTrusted => None,
99 SafetyClass::SandboxedExecution
100 | SafetyClass::LocalRuntimeExecution
101 | SafetyClass::NetworkEgress => Some(WRITE_SCOPE),
102 }
103}
104
105/// Where a handler executes relative to the Soma host process.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ExecutionMode {
108 /// Executes on the host (in-process, or a local runtime the host
109 /// spawns with host-level access).
110 Inline,
111 /// Executes inside an isolation boundary (e.g. a WASM sandbox).
112 Sandboxed,
113}
114
115impl ExecutionMode {
116 /// Execution mode implied by a provider manifest kind: only `wasm`
117 /// handlers run behind an isolation boundary today.
118 pub fn for_provider_kind(kind: &str) -> Self {
119 if kind == "wasm" {
120 Self::Sandboxed
121 } else {
122 Self::Inline
123 }
124 }
125}
126
127/// Who is asking for a dispatch. Fields are private on purpose: the only
128/// way to obtain `trusted_local = true` is [`Self::trusted_local_caller`].
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct CallerContext {
131 subject: String,
132 scopes: Vec<String>,
133 trusted_local: bool,
134}
135
136impl CallerContext {
137 /// The one constructor that grants local trust. Reserved for callers the
138 /// process itself vouches for: the CLI/loopback path and an explicitly
139 /// configured authz-enforcing trusted gateway.
140 pub fn trusted_local_caller(subject: impl Into<String>) -> Self {
141 Self {
142 subject: subject.into(),
143 scopes: Vec::new(),
144 trusted_local: true,
145 }
146 }
147
148 /// A remote caller carrying token scopes. `trusted_local` is hard-coded
149 /// `false`: no scope set a network caller presents can confer local trust.
150 pub fn remote_scoped(subject: impl Into<String>, scopes: Vec<String>) -> Self {
151 Self {
152 subject: subject.into(),
153 scopes,
154 trusted_local: false,
155 }
156 }
157
158 /// A caller that grants nothing: no scopes, no local trust.
159 pub fn anonymous() -> Self {
160 Self {
161 subject: "anonymous".to_owned(),
162 scopes: Vec::new(),
163 trusted_local: false,
164 }
165 }
166
167 /// The caller's subject identifier.
168 pub fn subject(&self) -> &str {
169 &self.subject
170 }
171
172 /// The scopes this caller presents.
173 pub fn scopes(&self) -> &[String] {
174 &self.scopes
175 }
176
177 /// Whether this caller was granted local trust.
178 pub fn is_trusted_local(&self) -> bool {
179 self.trusted_local
180 }
181}
182
183/// A decision object, not a bool: `allowed` plus a stable machine-readable
184/// `reason` from [`reasons`], plus human-oriented advisory `warnings`.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct SecurityDecision {
187 /// Whether the dispatch is permitted.
188 pub allowed: bool,
189 /// Stable machine-readable reason from [`reasons`].
190 pub reason: &'static str,
191 /// Human-oriented advisory messages (non-blocking).
192 pub warnings: Vec<String>,
193}
194
195impl SecurityDecision {
196 /// Builds an allowing decision with the given reason and no warnings.
197 pub fn allow(reason: &'static str) -> Self {
198 Self {
199 allowed: true,
200 reason,
201 warnings: Vec::new(),
202 }
203 }
204
205 /// Builds a denying decision with the given reason and no warnings.
206 pub fn deny(reason: &'static str) -> Self {
207 Self {
208 allowed: false,
209 reason,
210 warnings: Vec::new(),
211 }
212 }
213
214 /// Returns true if this decision denies the dispatch.
215 pub fn is_denied(&self) -> bool {
216 !self.allowed
217 }
218}
219
220/// Authorizes one dispatch: affinity scope + the trusted-local inline rule,
221/// deny-by-default for unclassified targets.
222///
223/// Trusted-local callers bypass affinity entirely — this keeps
224/// LoopbackDev/TrustedGateway behavior identical to the pre-authz world,
225/// where scope checks were skipped outside `Mounted` auth.
226pub fn authorize(
227 caller: &CallerContext,
228 safety_class: Option<SafetyClass>,
229 execution_mode: ExecutionMode,
230) -> SecurityDecision {
231 let Some(class) = safety_class else {
232 return SecurityDecision::deny(reasons::DENIED_UNCLASSIFIED_TARGET);
233 };
234 if caller.is_trusted_local() {
235 let mut decision = SecurityDecision::allow(reasons::AUTHORIZED_TRUSTED_LOCAL);
236 if required_scope_for_safety_class(class).is_some() {
237 decision.warnings.push(format!(
238 "trusted-local caller `{}` bypassed `{WRITE_SCOPE}` scope affinity for safety class `{}`",
239 caller.subject(),
240 class.as_str(),
241 ));
242 }
243 return decision;
244 }
245 let Some(required) = required_scope_for_safety_class(class) else {
246 return SecurityDecision::allow(reasons::AUTHORIZED_NO_AFFINITY_REQUIRED);
247 };
248 if !scopes_satisfy(caller.scopes(), required) {
249 return SecurityDecision::deny(reasons::DENIED_SCOPE_MISSING);
250 }
251 if execution_mode == ExecutionMode::Inline && class.requires_local_trust_when_inline() {
252 return SecurityDecision::deny(reasons::DENIED_AFFINITY_REQUIRES_LOCAL_TRUST);
253 }
254 SecurityDecision::allow(reasons::AUTHORIZED_SCOPE_SATISFIED)
255}
256
257/// Convenience for the provider-dispatch chokepoint: classify a manifest
258/// kind, derive its execution mode, and authorize in one call.
259pub fn authorize_provider_kind(caller: &CallerContext, provider_kind: &str) -> SecurityDecision {
260 authorize(
261 caller,
262 SafetyClass::classify_provider_kind(provider_kind),
263 ExecutionMode::for_provider_kind(provider_kind),
264 )
265}
266
267#[cfg(test)]
268#[path = "authz_tests.rs"]
269mod tests;