use std::collections::BTreeSet;
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthVerdict {
Allow,
Deny {
missing: Vec<String>,
required: Vec<String>,
have: Vec<String>,
},
}
pub fn is_valid_capability_slug(slug: &str) -> bool {
crate::parser::is_valid_capability_slug(slug)
}
pub fn extract_capabilities_from_bearer(headers: &HeaderMap) -> Vec<String> {
let auth = match headers.get("authorization").and_then(|v| v.to_str().ok()) {
Some(a) => a,
None => return Vec::new(),
};
let token = match auth.strip_prefix("Bearer ") {
Some(t) => t,
None => return Vec::new(),
};
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() < 2 {
return Vec::new();
}
let payload_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let claims: Value = match serde_json::from_slice(&payload_bytes) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let arr = match claims.get("capabilities").and_then(|v| v.as_array()) {
Some(a) => a,
None => return Vec::new(),
};
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
}
pub fn check_capabilities(declared: &[String], have: &[String]) -> AuthVerdict {
if declared.is_empty() {
return AuthVerdict::Allow;
}
let have_set: BTreeSet<&str> = have.iter().map(|s| s.as_str()).collect();
let missing: Vec<String> = declared
.iter()
.filter(|d| !have_set.contains(d.as_str()))
.cloned()
.collect();
if missing.is_empty() {
AuthVerdict::Allow
} else {
AuthVerdict::Deny {
missing,
required: declared.to_vec(),
have: have.to_vec(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn jwt_with_caps(caps: &[&str]) -> String {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\",\"typ\":\"JWT\"}");
let payload_json = serde_json::json!({"capabilities": caps});
let payload =
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
format!("{header}.{payload}.")
}
fn headers_with_auth(token: &str) -> HeaderMap {
let mut h = HeaderMap::new();
let value = format!("Bearer {token}");
h.insert("authorization", HeaderValue::from_str(&value).unwrap());
h
}
#[test]
fn no_bearer_returns_empty_capabilities() {
let h = HeaderMap::new();
assert!(extract_capabilities_from_bearer(&h).is_empty());
}
#[test]
fn malformed_token_returns_empty_capabilities() {
let h = headers_with_auth("not-a-jwt");
assert!(extract_capabilities_from_bearer(&h).is_empty());
}
#[test]
fn token_without_capabilities_claim_returns_empty() {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
let payload = URL_SAFE_NO_PAD.encode(b"{\"sub\":\"alice\"}");
let token = format!("{header}.{payload}.");
let h = headers_with_auth(&token);
assert!(extract_capabilities_from_bearer(&h).is_empty());
}
#[test]
fn extracts_array_of_capabilities() {
let h = headers_with_auth(&jwt_with_caps(&["admin", "legal.read"]));
let caps = extract_capabilities_from_bearer(&h);
assert_eq!(caps, vec!["admin".to_string(), "legal.read".to_string()]);
}
#[test]
fn check_allows_empty_required() {
let v = check_capabilities(&[], &[]);
assert_eq!(v, AuthVerdict::Allow);
let v = check_capabilities(&[], &["admin".to_string()]);
assert_eq!(v, AuthVerdict::Allow);
}
#[test]
fn check_allows_exact_match() {
let v = check_capabilities(
&["admin".to_string()],
&["admin".to_string()],
);
assert_eq!(v, AuthVerdict::Allow);
}
#[test]
fn check_allows_superset() {
let v = check_capabilities(
&["admin".to_string()],
&["admin".to_string(), "other".to_string()],
);
assert_eq!(v, AuthVerdict::Allow);
}
#[test]
fn check_denies_missing() {
let v = check_capabilities(
&["admin".to_string(), "legal.read".to_string()],
&["admin".to_string()],
);
match v {
AuthVerdict::Deny { missing, required, have } => {
assert_eq!(missing, vec!["legal.read".to_string()]);
assert_eq!(required.len(), 2);
assert_eq!(have.len(), 1);
}
_ => panic!("expected Deny"),
}
}
#[test]
fn check_denies_empty_have() {
let v = check_capabilities(
&["admin".to_string()],
&[],
);
match v {
AuthVerdict::Deny { missing, .. } => {
assert_eq!(missing, vec!["admin".to_string()]);
}
_ => panic!("expected Deny"),
}
}
#[test]
fn check_preserves_declaration_order_in_missing() {
let v = check_capabilities(
&[
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
],
&["b".to_string()],
);
match v {
AuthVerdict::Deny { missing, .. } => {
assert_eq!(missing, vec!["a", "c", "d"]);
}
_ => panic!("expected Deny"),
}
}
#[test]
fn capabilities_claim_with_non_string_values_drops_them() {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
let payload = URL_SAFE_NO_PAD.encode(
b"{\"capabilities\":[\"admin\",42,null,\"legal.read\"]}",
);
let token = format!("{header}.{payload}.");
let h = headers_with_auth(&token);
let caps = extract_capabilities_from_bearer(&h);
assert_eq!(caps, vec!["admin".to_string(), "legal.read".to_string()]);
}
#[test]
fn slug_validator_round_trip_with_parser() {
assert!(is_valid_capability_slug("admin"));
assert!(is_valid_capability_slug("legal.read"));
assert!(!is_valid_capability_slug("Admin"));
assert!(!is_valid_capability_slug("bank-officer"));
assert!(!is_valid_capability_slug(""));
}
}