1use 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#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum AuthVerdict {
59 Allow,
61 Deny {
65 missing: Vec<String>,
66 required: Vec<String>,
67 have: Vec<String>,
68 },
69}
70
71pub fn is_valid_capability_slug(slug: &str) -> bool {
76 crate::parser::is_valid_capability_slug(slug)
77}
78
79pub 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
125pub 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 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 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 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 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}