Skip to main content

axon/
auth_scope.rs

1//! §Fase 32.g — Auth scope (capability subset matching) for first-class
2//! axonendpoint routes.
3//!
4//! D8 ratificada 2026-05-11. When an axonendpoint declares
5//! `requires: [admin, legal.read, …]`, the runtime verifies the request
6//! bearer's capabilities contain every declared capability (AND
7//! semantics). Missing capability → 403 Forbidden with a structured
8//! error so the client KNOWS which capability is needed.
9//!
10//! ## Closed slug grammar (mirror of `axon_frontend::parser::is_valid_capability_slug`)
11//!
12//! `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
13//!
14//! - Each segment starts with a lowercase letter.
15//! - Each segment contains lowercase letters, digits, or underscores.
16//! - Segments separated by single dots.
17//!
18//! Closed grammar refuses interpretation drift — adopters can't
19//! accidentally introduce slug shapes that fail to match in production
20//! (e.g. `Admin`, `legal-read`).
21//!
22//! ## OSS vs enterprise capability surface
23//!
24//! - **OSS (this module)**: capabilities are read from the JWT bearer's
25//!   `capabilities` claim. No signature verification at this layer
26//!   (signature is verified by `tenant_extractor_middleware` upstream
27//!   when JWKS is configured). The unverified-decode path matches the
28//!   existing `tenant_id_from_bearer_unverified` precedent for single-
29//!   tenant / dev installs.
30//! - **Enterprise** (Fase 21 integration surface): capabilities are
31//!   registered + version-introspected via `/.well-known/axon-
32//!   capabilities`; auditors verify the runtime's capability set matches
33//!   the deployed source. Layered on top of this OSS primitive.
34//!
35//! ## Pillar trace per D12
36//!
37//! - **PHILOSOPHY** — the access contract IS the source declaration:
38//!   auditors read source + KNOW which endpoints require which
39//!   capabilities. No middleware side-channel.
40//! - **LOGIC** — `declared_requires ⊆ token_capabilities` is the
41//!   precise subset predicate; total boolean over the two sets.
42//! - **MATHEMATICS** — capability matching is set-theoretic: subset
43//!   check is associative, idempotent, and decidable.
44//! - **COMPUTING** — D9 backwards-compat absolute: empty `requires`
45//!   list short-circuits to pass-through; no behavior change for
46//!   v1.20.x–v1.22.x adopters.
47
48use std::collections::BTreeSet;
49
50use axum::http::HeaderMap;
51use base64::engine::general_purpose::URL_SAFE_NO_PAD;
52use base64::Engine;
53use serde_json::Value;
54
55/// Result of an auth-scope check. Total enum — every input maps to
56/// exactly one variant.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum AuthVerdict {
59    /// No `requires:` declared, OR bearer holds every required slug.
60    Allow,
61    /// Bearer is missing one or more declared capabilities. The
62    /// payload surfaces BOTH lists so the adopter client can correct
63    /// the request without server-log diving (precise, auditable).
64    Deny {
65        missing: Vec<String>,
66        required: Vec<String>,
67        have: Vec<String>,
68    },
69}
70
71/// Re-export the parser-layer slug validator so runtime callers
72/// (config loaders, dynamic ingestion paths, fuzz harnesses) hit the
73/// same predicate the parser enforces. Single source of truth across
74/// compile time + runtime + tests.
75pub fn is_valid_capability_slug(slug: &str) -> bool {
76    crate::parser::is_valid_capability_slug(slug)
77}
78
79/// Extract the bearer's capability slugs from `Authorization: Bearer
80/// <jwt>`. Returns an empty vec when there's no bearer, the token
81/// isn't a structurally-valid JWT, or the payload doesn't carry a
82/// `capabilities` claim.
83///
84/// Signature verification is performed UPSTREAM by
85/// `tenant_extractor_middleware` when `AXON_JWT_JWKS_URL` is set.
86/// In OSS / single-tenant installs without JWKS, this decode is
87/// unverified — matching the existing precedent in
88/// `axon::tenant::tenant_id_from_bearer_unverified`. Adopters in
89/// production who need verified extraction layer enterprise auth
90/// middleware on top.
91///
92/// The `capabilities` claim MUST be a JSON array of strings. Other
93/// shapes (object, scalar) are treated as "no capabilities" — be
94/// strict in what we accept (D8 + LOGIC pillar).
95pub fn extract_capabilities_from_bearer(headers: &HeaderMap) -> Vec<String> {
96    let auth = match headers.get("authorization").and_then(|v| v.to_str().ok()) {
97        Some(a) => a,
98        None => return Vec::new(),
99    };
100    let token = match auth.strip_prefix("Bearer ") {
101        Some(t) => t,
102        None => return Vec::new(),
103    };
104    let parts: Vec<&str> = token.splitn(3, '.').collect();
105    if parts.len() < 2 {
106        return Vec::new();
107    }
108    let payload_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) {
109        Ok(b) => b,
110        Err(_) => return Vec::new(),
111    };
112    let claims: Value = match serde_json::from_slice(&payload_bytes) {
113        Ok(c) => c,
114        Err(_) => return Vec::new(),
115    };
116    let arr = match claims.get("capabilities").and_then(|v| v.as_array()) {
117        Some(a) => a,
118        None => return Vec::new(),
119    };
120    arr.iter()
121        .filter_map(|v| v.as_str().map(|s| s.to_string()))
122        .collect()
123}
124
125/// Subset check: are all `declared` capabilities present in `have`?
126///
127/// Total predicate. Empty `declared` returns `Allow` unconditionally
128/// (D9 backwards-compat). Returns `Deny` with `missing` = `declared \
129/// have`, preserving declaration order for client-side diagnostic
130/// continuity.
131pub fn check_capabilities(declared: &[String], have: &[String]) -> AuthVerdict {
132    if declared.is_empty() {
133        return AuthVerdict::Allow;
134    }
135    let have_set: BTreeSet<&str> = have.iter().map(|s| s.as_str()).collect();
136    let missing: Vec<String> = declared
137        .iter()
138        .filter(|d| !have_set.contains(d.as_str()))
139        .cloned()
140        .collect();
141    if missing.is_empty() {
142        AuthVerdict::Allow
143    } else {
144        AuthVerdict::Deny {
145            missing,
146            required: declared.to_vec(),
147            have: have.to_vec(),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use axum::http::HeaderValue;
156
157    fn jwt_with_caps(caps: &[&str]) -> String {
158        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\",\"typ\":\"JWT\"}");
159        let payload_json = serde_json::json!({"capabilities": caps});
160        let payload =
161            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
162        format!("{header}.{payload}.")
163    }
164
165    fn headers_with_auth(token: &str) -> HeaderMap {
166        let mut h = HeaderMap::new();
167        let value = format!("Bearer {token}");
168        h.insert("authorization", HeaderValue::from_str(&value).unwrap());
169        h
170    }
171
172    #[test]
173    fn no_bearer_returns_empty_capabilities() {
174        let h = HeaderMap::new();
175        assert!(extract_capabilities_from_bearer(&h).is_empty());
176    }
177
178    #[test]
179    fn malformed_token_returns_empty_capabilities() {
180        let h = headers_with_auth("not-a-jwt");
181        assert!(extract_capabilities_from_bearer(&h).is_empty());
182    }
183
184    #[test]
185    fn token_without_capabilities_claim_returns_empty() {
186        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
187        let payload = URL_SAFE_NO_PAD.encode(b"{\"sub\":\"alice\"}");
188        let token = format!("{header}.{payload}.");
189        let h = headers_with_auth(&token);
190        assert!(extract_capabilities_from_bearer(&h).is_empty());
191    }
192
193    #[test]
194    fn extracts_array_of_capabilities() {
195        let h = headers_with_auth(&jwt_with_caps(&["admin", "legal.read"]));
196        let caps = extract_capabilities_from_bearer(&h);
197        assert_eq!(caps, vec!["admin".to_string(), "legal.read".to_string()]);
198    }
199
200    #[test]
201    fn check_allows_empty_required() {
202        // D9 backwards-compat path.
203        let v = check_capabilities(&[], &[]);
204        assert_eq!(v, AuthVerdict::Allow);
205        let v = check_capabilities(&[], &["admin".to_string()]);
206        assert_eq!(v, AuthVerdict::Allow);
207    }
208
209    #[test]
210    fn check_allows_exact_match() {
211        let v = check_capabilities(
212            &["admin".to_string()],
213            &["admin".to_string()],
214        );
215        assert_eq!(v, AuthVerdict::Allow);
216    }
217
218    #[test]
219    fn check_allows_superset() {
220        let v = check_capabilities(
221            &["admin".to_string()],
222            &["admin".to_string(), "other".to_string()],
223        );
224        assert_eq!(v, AuthVerdict::Allow);
225    }
226
227    #[test]
228    fn check_denies_missing() {
229        let v = check_capabilities(
230            &["admin".to_string(), "legal.read".to_string()],
231            &["admin".to_string()],
232        );
233        match v {
234            AuthVerdict::Deny { missing, required, have } => {
235                assert_eq!(missing, vec!["legal.read".to_string()]);
236                assert_eq!(required.len(), 2);
237                assert_eq!(have.len(), 1);
238            }
239            _ => panic!("expected Deny"),
240        }
241    }
242
243    #[test]
244    fn check_denies_empty_have() {
245        let v = check_capabilities(
246            &["admin".to_string()],
247            &[],
248        );
249        match v {
250            AuthVerdict::Deny { missing, .. } => {
251                assert_eq!(missing, vec!["admin".to_string()]);
252            }
253            _ => panic!("expected Deny"),
254        }
255    }
256
257    #[test]
258    fn check_preserves_declaration_order_in_missing() {
259        // Diagnostic continuity — adopter sees missing in source-
260        // declaration order, not hash-table order.
261        let v = check_capabilities(
262            &[
263                "a".to_string(),
264                "b".to_string(),
265                "c".to_string(),
266                "d".to_string(),
267            ],
268            &["b".to_string()],
269        );
270        match v {
271            AuthVerdict::Deny { missing, .. } => {
272                assert_eq!(missing, vec!["a", "c", "d"]);
273            }
274            _ => panic!("expected Deny"),
275        }
276    }
277
278    #[test]
279    fn capabilities_claim_with_non_string_values_drops_them() {
280        // Defensive: a malformed token containing mixed types in the
281        // capabilities array drops the non-strings (extract_*) which
282        // preserves the strict type contract — capabilities are
283        // strings, period.
284        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
285        let payload = URL_SAFE_NO_PAD.encode(
286            b"{\"capabilities\":[\"admin\",42,null,\"legal.read\"]}",
287        );
288        let token = format!("{header}.{payload}.");
289        let h = headers_with_auth(&token);
290        let caps = extract_capabilities_from_bearer(&h);
291        assert_eq!(caps, vec!["admin".to_string(), "legal.read".to_string()]);
292    }
293
294    #[test]
295    fn slug_validator_round_trip_with_parser() {
296        // Anchor: the runtime + parser share ONE predicate. Confirm
297        // a sample of accepted + rejected slugs.
298        assert!(is_valid_capability_slug("admin"));
299        assert!(is_valid_capability_slug("legal.read"));
300        assert!(!is_valid_capability_slug("Admin"));
301        assert!(!is_valid_capability_slug("bank-officer"));
302        assert!(!is_valid_capability_slug(""));
303    }
304}