use std::collections::BTreeSet;
use 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(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Capability(String);
impl Capability {
pub fn parse(slug: &str) -> Option<Capability> {
if is_valid_capability_slug(slug) {
Some(Capability(slug.to_string()))
} else {
None
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for Capability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectionError {
NotProjectable(String),
}
impl std::fmt::Display for ProjectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectionError::NotProjectable(s) => {
write!(f, "authority `{s}` does not project to a valid capability slug")
}
}
}
}
pub fn project_permission(perm: &str) -> Result<Capability, ProjectionError> {
let colon_count = perm.bytes().filter(|b| *b == b':').count();
let candidate = match colon_count {
0 => perm.to_string(),
1 => perm.replace(':', "."),
_ => return Err(ProjectionError::NotProjectable(perm.to_string())),
};
Capability::parse(&candidate).ok_or_else(|| ProjectionError::NotProjectable(perm.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectedSet {
pub capabilities: BTreeSet<Capability>,
pub dropped: Vec<(String, ProjectionError)>,
}
impl ProjectedSet {
pub fn is_total(&self) -> bool {
self.dropped.is_empty()
}
}
pub fn project_permission_set<I, S>(authorities: I) -> ProjectedSet
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut capabilities: BTreeSet<Capability> = BTreeSet::new();
let mut dropped: Vec<(String, ProjectionError)> = Vec::new();
for auth in authorities {
let auth = auth.as_ref();
match project_permission(auth) {
Ok(cap) => {
capabilities.insert(cap);
}
Err(e) => dropped.push((auth.to_string(), e)),
}
}
ProjectedSet {
capabilities,
dropped,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantableSet {
pub caps: BTreeSet<Capability>,
pub collisions: Vec<(String, String, String)>,
pub unprojectable: Vec<String>,
}
impl GrantableSet {
pub fn is_clean(&self) -> bool {
self.collisions.is_empty() && self.unprojectable.is_empty()
}
}
pub fn build_grantable_set<I, S>(authorities: I) -> GrantableSet
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
use std::collections::BTreeMap;
let mut origin: BTreeMap<String, String> = BTreeMap::new();
let mut caps: BTreeSet<Capability> = BTreeSet::new();
let mut collisions: Vec<(String, String, String)> = Vec::new();
let mut unprojectable: Vec<String> = Vec::new();
for auth in authorities {
let auth = auth.as_ref();
match project_permission(auth) {
Ok(cap) => {
let key = cap.as_str().to_string();
if let Some(prev) = origin.get(&key) {
if prev != auth {
collisions.push((prev.clone(), auth.to_string(), key.clone()));
}
} else {
origin.insert(key, auth.to_string());
caps.insert(cap);
}
}
Err(ProjectionError::NotProjectable(s)) => unprojectable.push(s),
}
}
GrantableSet {
caps,
collisions,
unprojectable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantabilityVerdict {
Grantable,
Ungrantable {
ungrantable: Vec<String>,
required: Vec<String>,
},
}
pub fn check_grantable(
requires: &[String],
grantable: &BTreeSet<Capability>,
) -> GrantabilityVerdict {
let grantable_strs: BTreeSet<&str> = grantable.iter().map(|c| c.as_str()).collect();
let ungrantable: Vec<String> = requires
.iter()
.filter(|r| !grantable_strs.contains(r.as_str()))
.cloned()
.collect();
if ungrantable.is_empty() {
GrantabilityVerdict::Grantable
} else {
GrantabilityVerdict::Ungrantable {
ungrantable,
required: requires.to_vec(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use 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(""));
}
const CATALOG_SAMPLE: &[&str] = &[
"flow:execute",
"flow:deploy",
"tenant:update",
"secret:read",
"secret:write",
"warden:execute",
"savant:execute",
"tech:dispatch",
"tech:approve",
"daemon:run",
];
#[test]
fn pi_projects_colon_perm_to_dotted_capability() {
assert_eq!(project_permission("flow:execute").unwrap().as_str(), "flow.execute");
assert_eq!(project_permission("tenant:update").unwrap().as_str(), "tenant.update");
assert_eq!(project_permission("secret:read").unwrap().as_str(), "secret.read");
}
#[test]
fn pi_is_identity_on_already_canonical_dotted_caps() {
assert_eq!(project_permission("store.platform_read").unwrap().as_str(), "store.platform_read");
assert_eq!(project_permission("chat.invoke").unwrap().as_str(), "chat.invoke");
assert_eq!(project_permission("admin").unwrap().as_str(), "admin");
}
#[test]
fn pi_is_total_over_the_catalog() {
for perm in CATALOG_SAMPLE {
let cap = project_permission(perm)
.unwrap_or_else(|_| panic!("π must be defined on catalog perm `{perm}`"));
assert!(is_valid_capability_slug(cap.as_str()));
}
}
#[test]
fn pi_is_injective_on_the_single_colon_catalog() {
let images: BTreeSet<String> = CATALOG_SAMPLE
.iter()
.map(|p| project_permission(p).unwrap().into_string())
.collect();
let sources: BTreeSet<&str> = CATALOG_SAMPLE.iter().copied().collect();
assert_eq!(images.len(), sources.len(), "π collapsed two distinct authorities");
}
#[test]
fn pi_rejects_multi_colon_and_malformed() {
assert!(matches!(project_permission("a:b:c"), Err(ProjectionError::NotProjectable(_))));
assert!(matches!(project_permission("Flow:Execute"), Err(ProjectionError::NotProjectable(_))));
assert!(matches!(project_permission("bank-officer:read"), Err(ProjectionError::NotProjectable(_))));
assert!(matches!(project_permission(""), Err(ProjectionError::NotProjectable(_))));
}
#[test]
fn build_grantable_set_is_clean_for_disjoint_catalog_plus_reserved() {
let mut authorities: Vec<&str> = CATALOG_SAMPLE.to_vec();
authorities.push("store.platform_read");
authorities.push("store.platform_write");
let g = build_grantable_set(authorities);
assert!(g.is_clean(), "collisions={:?} unprojectable={:?}", g.collisions, g.unprojectable);
assert!(g.caps.contains(&Capability::parse("flow.execute").unwrap()));
assert!(g.caps.contains(&Capability::parse("store.platform_read").unwrap()));
}
#[test]
fn build_grantable_set_detects_a_fractured_namespace() {
let g = build_grantable_set(vec!["store:platform_read", "store.platform_read"]);
assert!(!g.is_clean());
assert_eq!(g.collisions.len(), 1);
assert_eq!(g.collisions[0].2, "store.platform_read");
}
#[test]
fn build_grantable_set_is_idempotent_on_duplicate_authority() {
let g = build_grantable_set(vec!["flow:execute", "flow:execute"]);
assert!(g.is_clean());
assert_eq!(g.caps.len(), 1);
}
#[test]
fn grantability_law_admits_a_projected_requirement() {
let g = build_grantable_set(CATALOG_SAMPLE.to_vec());
let v = check_grantable(&["flow.execute".to_string()], &g.caps);
assert_eq!(v, GrantabilityVerdict::Grantable);
}
#[test]
fn grantability_law_rejects_a_dead_requirement() {
let g = build_grantable_set(CATALOG_SAMPLE.to_vec());
let v = check_grantable(&["tenant.write".to_string()], &g.caps);
match v {
GrantabilityVerdict::Ungrantable { ungrantable, .. } => {
assert_eq!(ungrantable, vec!["tenant.write".to_string()]);
}
_ => panic!("expected Ungrantable — tenant.write is not grantable"),
}
}
#[test]
fn project_set_is_total_over_a_clean_held_set() {
let p = project_permission_set(vec![
"tenant:update",
"secret:write",
"store.platform_read",
]);
assert!(p.is_total());
assert!(p.capabilities.contains(&Capability::parse("tenant.update").unwrap()));
assert!(p.capabilities.contains(&Capability::parse("secret.write").unwrap()));
assert!(p.capabilities.contains(&Capability::parse("store.platform_read").unwrap()));
}
#[test]
fn project_set_surfaces_drops_instead_of_swallowing() {
let p = project_permission_set(vec!["tenant:update", "a:b:c", "Bad-Key"]);
assert!(!p.is_total());
assert_eq!(p.capabilities.len(), 1);
assert_eq!(p.dropped.len(), 2);
assert_eq!(p.dropped[0].0, "a:b:c");
assert_eq!(p.dropped[1].0, "Bad-Key");
}
#[test]
fn project_set_is_idempotent_and_collision_tolerant() {
let p = project_permission_set(vec!["flow:execute", "flow:execute", "flow.execute"]);
assert!(p.is_total());
assert_eq!(p.capabilities.len(), 1);
}
#[test]
fn project_set_of_empty_is_empty_and_total() {
let p = project_permission_set(Vec::<&str>::new());
assert!(p.is_total());
assert!(p.capabilities.is_empty());
}
#[test]
fn grantability_law_preserves_declaration_order() {
let g = build_grantable_set(CATALOG_SAMPLE.to_vec());
let v = check_grantable(
&["zzz.dead".to_string(), "flow.execute".to_string(), "aaa.dead".to_string()],
&g.caps,
);
match v {
GrantabilityVerdict::Ungrantable { ungrantable, .. } => {
assert_eq!(ungrantable, vec!["zzz.dead".to_string(), "aaa.dead".to_string()]);
}
_ => panic!("expected Ungrantable"),
}
}
}