Skip to main content

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;