use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Permission {
Read,
Write,
Admin,
Delete,
Execute,
}
#[derive(Debug, Clone)]
pub struct AccessPolicy {
pub resource: String,
pub allowed_dids: Vec<String>,
pub allowed_permissions: Vec<Permission>,
pub deny_dids: Vec<String>,
pub expiry_ms: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct AccessRequest {
pub did: String,
pub resource: String,
pub permission: Permission,
pub timestamp_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessDecision {
Allow,
Deny(String),
Expired,
NotFound,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AclError {
PolicyNotFound(String),
DuplicateDid(String),
}
impl std::fmt::Display for AclError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AclError::PolicyNotFound(r) => write!(f, "policy not found for resource: {}", r),
AclError::DuplicateDid(d) => write!(f, "DID already in allow list: {}", d),
}
}
}
pub struct AccessControlList {
policies: HashMap<String, AccessPolicy>,
}
impl AccessControlList {
pub fn new() -> Self {
AccessControlList {
policies: HashMap::new(),
}
}
pub fn add_policy(&mut self, policy: AccessPolicy) {
self.policies.insert(policy.resource.clone(), policy);
}
pub fn remove_policy(&mut self, resource: &str) -> bool {
self.policies.remove(resource).is_some()
}
pub fn check(&self, req: &AccessRequest) -> AccessDecision {
let policy = match self.policies.get(&req.resource) {
Some(p) => p,
None => return AccessDecision::NotFound,
};
if let Some(expiry) = policy.expiry_ms {
if req.timestamp_ms >= expiry {
return AccessDecision::Expired;
}
}
if policy.deny_dids.contains(&req.did) {
return AccessDecision::Deny("DID explicitly denied".to_string());
}
if !policy.allowed_dids.contains(&req.did) {
return AccessDecision::Deny("DID not in allow list".to_string());
}
let has_admin = policy.allowed_permissions.contains(&Permission::Admin);
let has_permission = has_admin || policy.allowed_permissions.contains(&req.permission);
if has_permission {
AccessDecision::Allow
} else {
AccessDecision::Deny("permission not granted".to_string())
}
}
pub fn grant(&mut self, resource: &str, did: &str, permission: Permission) -> Result<(), AclError> {
let policy = self
.policies
.get_mut(resource)
.ok_or_else(|| AclError::PolicyNotFound(resource.to_string()))?;
if !policy.allowed_dids.contains(&did.to_string()) {
policy.allowed_dids.push(did.to_string());
}
if !policy.allowed_permissions.contains(&permission) {
policy.allowed_permissions.push(permission);
}
Ok(())
}
pub fn revoke(&mut self, resource: &str, did: &str) -> bool {
if let Some(policy) = self.policies.get_mut(resource) {
let before = policy.allowed_dids.len();
policy.allowed_dids.retain(|d| d != did);
policy.allowed_dids.len() < before
} else {
false
}
}
pub fn deny_did(&mut self, resource: &str, did: &str) -> Result<(), AclError> {
let policy = self
.policies
.get_mut(resource)
.ok_or_else(|| AclError::PolicyNotFound(resource.to_string()))?;
if !policy.deny_dids.contains(&did.to_string()) {
policy.deny_dids.push(did.to_string());
}
Ok(())
}
pub fn policies_for_did(&self, did: &str) -> Vec<&AccessPolicy> {
self.policies
.values()
.filter(|p| p.allowed_dids.contains(&did.to_string()))
.collect()
}
pub fn expired_policies(&self, current_time_ms: u64) -> Vec<&AccessPolicy> {
self.policies
.values()
.filter(|p| {
p.expiry_ms
.map(|e| current_time_ms >= e)
.unwrap_or(false)
})
.collect()
}
pub fn purge_expired(&mut self, current_time_ms: u64) -> usize {
let expired_resources: Vec<String> = self
.policies
.iter()
.filter(|(_, p)| p.expiry_ms.map(|e| current_time_ms >= e).unwrap_or(false))
.map(|(k, _)| k.clone())
.collect();
let count = expired_resources.len();
for resource in expired_resources {
self.policies.remove(&resource);
}
count
}
pub fn policy_count(&self) -> usize {
self.policies.len()
}
}
impl Default for AccessControlList {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_acl() -> AccessControlList {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/graph/data".to_string(),
allowed_dids: vec!["did:key:alice".to_string(), "did:key:bob".to_string()],
allowed_permissions: vec![Permission::Read, Permission::Write],
deny_dids: vec![],
expiry_ms: None,
});
acl.add_policy(AccessPolicy {
resource: "/graph/admin".to_string(),
allowed_dids: vec!["did:key:admin".to_string()],
allowed_permissions: vec![Permission::Admin],
deny_dids: vec![],
expiry_ms: None,
});
acl
}
fn make_request(did: &str, resource: &str, permission: Permission) -> AccessRequest {
AccessRequest {
did: did.to_string(),
resource: resource.to_string(),
permission,
timestamp_ms: 1_000_000,
}
}
#[test]
fn test_allow_read() {
let acl = make_acl();
let req = make_request("did:key:alice", "/graph/data", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_allow_write() {
let acl = make_acl();
let req = make_request("did:key:alice", "/graph/data", Permission::Write);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_allow_multiple_dids() {
let acl = make_acl();
let req = make_request("did:key:bob", "/graph/data", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_deny_unknown_did() {
let acl = make_acl();
let req = make_request("did:key:unknown", "/graph/data", Permission::Read);
assert!(matches!(acl.check(&req), AccessDecision::Deny(_)));
}
#[test]
fn test_deny_wrong_permission() {
let acl = make_acl();
let req = make_request("did:key:alice", "/graph/data", Permission::Delete);
assert!(matches!(acl.check(&req), AccessDecision::Deny(_)));
}
#[test]
fn test_deny_explicit_deny_list() {
let mut acl = make_acl();
acl.deny_did("/graph/data", "did:key:alice").unwrap();
let req = make_request("did:key:alice", "/graph/data", Permission::Read);
assert!(matches!(acl.check(&req), AccessDecision::Deny(msg) if msg.contains("explicitly denied")));
}
#[test]
fn test_deny_overrides_allow() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/res".to_string(),
allowed_dids: vec!["did:key:alice".to_string()],
allowed_permissions: vec![Permission::Read],
deny_dids: vec!["did:key:alice".to_string()], expiry_ms: None,
});
let req = make_request("did:key:alice", "/res", Permission::Read);
assert!(matches!(acl.check(&req), AccessDecision::Deny(_)));
}
#[test]
fn test_not_found_unknown_resource() {
let acl = make_acl();
let req = make_request("did:key:alice", "/nonexistent", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::NotFound);
}
#[test]
fn test_not_found_after_remove() {
let mut acl = make_acl();
acl.remove_policy("/graph/data");
let req = make_request("did:key:alice", "/graph/data", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::NotFound);
}
#[test]
fn test_expired_policy() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/tmp/resource".to_string(),
allowed_dids: vec!["did:key:alice".to_string()],
allowed_permissions: vec![Permission::Read],
deny_dids: vec![],
expiry_ms: Some(5000),
});
let req = AccessRequest {
did: "did:key:alice".to_string(),
resource: "/tmp/resource".to_string(),
permission: Permission::Read,
timestamp_ms: 5001, };
assert_eq!(acl.check(&req), AccessDecision::Expired);
}
#[test]
fn test_not_expired_policy_before_expiry() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/tmp/resource".to_string(),
allowed_dids: vec!["did:key:alice".to_string()],
allowed_permissions: vec![Permission::Read],
deny_dids: vec![],
expiry_ms: Some(10_000),
});
let req = AccessRequest {
did: "did:key:alice".to_string(),
resource: "/tmp/resource".to_string(),
permission: Permission::Read,
timestamp_ms: 9_999,
};
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_admin_allows_read() {
let acl = make_acl();
let req = make_request("did:key:admin", "/graph/admin", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::Allow, "Admin should imply Read");
}
#[test]
fn test_admin_allows_write() {
let acl = make_acl();
let req = make_request("did:key:admin", "/graph/admin", Permission::Write);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_admin_allows_delete() {
let acl = make_acl();
let req = make_request("did:key:admin", "/graph/admin", Permission::Delete);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_admin_allows_execute() {
let acl = make_acl();
let req = make_request("did:key:admin", "/graph/admin", Permission::Execute);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_grant_adds_did() {
let mut acl = make_acl();
acl.grant("/graph/data", "did:key:carol", Permission::Read).unwrap();
let req = make_request("did:key:carol", "/graph/data", Permission::Read);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_grant_adds_permission() {
let mut acl = make_acl();
acl.grant("/graph/data", "did:key:alice", Permission::Delete).unwrap();
let req = make_request("did:key:alice", "/graph/data", Permission::Delete);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_grant_policy_not_found() {
let mut acl = make_acl();
let result = acl.grant("/nonexistent", "did:key:carol", Permission::Read);
assert!(matches!(result, Err(AclError::PolicyNotFound(_))));
}
#[test]
fn test_revoke_removes_did() {
let mut acl = make_acl();
let removed = acl.revoke("/graph/data", "did:key:alice");
assert!(removed);
let req = make_request("did:key:alice", "/graph/data", Permission::Read);
assert!(matches!(acl.check(&req), AccessDecision::Deny(_)));
}
#[test]
fn test_revoke_returns_false_when_did_not_present() {
let mut acl = make_acl();
let removed = acl.revoke("/graph/data", "did:key:nobody");
assert!(!removed);
}
#[test]
fn test_revoke_on_missing_resource_returns_false() {
let mut acl = make_acl();
let removed = acl.revoke("/nonexistent", "did:key:alice");
assert!(!removed);
}
#[test]
fn test_deny_did_policy_not_found() {
let mut acl = make_acl();
let result = acl.deny_did("/nonexistent", "did:key:alice");
assert!(matches!(result, Err(AclError::PolicyNotFound(_))));
}
#[test]
fn test_policies_for_did_returns_matching() {
let acl = make_acl();
let policies = acl.policies_for_did("did:key:alice");
assert_eq!(policies.len(), 1);
assert_eq!(policies[0].resource, "/graph/data");
}
#[test]
fn test_policies_for_did_returns_empty_for_unknown() {
let acl = make_acl();
let policies = acl.policies_for_did("did:key:nobody");
assert!(policies.is_empty());
}
#[test]
fn test_policies_for_did_multiple_resources() {
let mut acl = make_acl();
acl.grant("/graph/admin", "did:key:alice", Permission::Read).unwrap();
let policies = acl.policies_for_did("did:key:alice");
assert_eq!(policies.len(), 2);
}
#[test]
fn test_expired_policies_returns_expired() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/old".to_string(),
allowed_dids: vec![],
allowed_permissions: vec![],
deny_dids: vec![],
expiry_ms: Some(100),
});
let expired = acl.expired_policies(200);
assert_eq!(expired.len(), 1);
}
#[test]
fn test_expired_policies_excludes_active() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/active".to_string(),
allowed_dids: vec![],
allowed_permissions: vec![],
deny_dids: vec![],
expiry_ms: Some(9999),
});
let expired = acl.expired_policies(100);
assert!(expired.is_empty());
}
#[test]
fn test_purge_expired_removes_policies() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/old1".to_string(),
allowed_dids: vec![],
allowed_permissions: vec![],
deny_dids: vec![],
expiry_ms: Some(100),
});
acl.add_policy(AccessPolicy {
resource: "/old2".to_string(),
allowed_dids: vec![],
allowed_permissions: vec![],
deny_dids: vec![],
expiry_ms: Some(200),
});
acl.add_policy(AccessPolicy {
resource: "/active".to_string(),
allowed_dids: vec![],
allowed_permissions: vec![],
deny_dids: vec![],
expiry_ms: None,
});
let count = acl.purge_expired(300);
assert_eq!(count, 2);
assert_eq!(acl.policy_count(), 1);
}
#[test]
fn test_purge_expired_returns_zero_when_nothing_expired() {
let mut acl = make_acl();
let count = acl.purge_expired(0);
assert_eq!(count, 0);
}
#[test]
fn test_policy_count_empty() {
let acl = AccessControlList::new();
assert_eq!(acl.policy_count(), 0);
}
#[test]
fn test_policy_count_after_add() {
let acl = make_acl();
assert_eq!(acl.policy_count(), 2);
}
#[test]
fn test_policy_count_after_remove() {
let mut acl = make_acl();
acl.remove_policy("/graph/data");
assert_eq!(acl.policy_count(), 1);
}
#[test]
fn test_permission_execute() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/exec".to_string(),
allowed_dids: vec!["did:key:runner".to_string()],
allowed_permissions: vec![Permission::Execute],
deny_dids: vec![],
expiry_ms: None,
});
let req = make_request("did:key:runner", "/exec", Permission::Execute);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_permission_delete() {
let mut acl = AccessControlList::new();
acl.add_policy(AccessPolicy {
resource: "/delete".to_string(),
allowed_dids: vec!["did:key:deleter".to_string()],
allowed_permissions: vec![Permission::Delete],
deny_dids: vec![],
expiry_ms: None,
});
let req = make_request("did:key:deleter", "/delete", Permission::Delete);
assert_eq!(acl.check(&req), AccessDecision::Allow);
}
#[test]
fn test_remove_policy_returns_false_when_not_found() {
let mut acl = make_acl();
assert!(!acl.remove_policy("/does_not_exist"));
}
#[test]
fn test_default_acl_is_empty() {
let acl = AccessControlList::default();
assert_eq!(acl.policy_count(), 0);
}
}