#[cfg(test)]
use std::collections::HashMap;
use std::collections::{HashSet, VecDeque};
use graph_engine::{Direction, GraphEngine, PropertyValue};
use crate::attenuation::AttenuationPolicy;
use crate::signing::EdgeSigner;
use crate::Permission;
pub struct AccessController;
fn ensure_entity_key_index(graph: &GraphEngine) {
let _ = graph.create_node_property_index("entity_key");
}
#[cfg(test)]
fn get_or_create_entity_node(graph: &GraphEngine, entity_key: &str) -> u64 {
ensure_entity_key_index(graph);
if let Ok(nodes) =
graph.find_nodes_by_property("entity_key", &PropertyValue::String(entity_key.to_string()))
{
if let Some(node) = nodes.first() {
return node.id;
}
}
let mut props = HashMap::new();
props.insert(
"entity_key".to_string(),
PropertyValue::String(entity_key.to_string()),
);
graph.create_node("AccessEntity", props).unwrap_or(0)
}
fn find_entity_node(graph: &GraphEngine, entity_key: &str) -> Option<u64> {
ensure_entity_key_index(graph);
graph
.find_nodes_by_property("entity_key", &PropertyValue::String(entity_key.to_string()))
.ok()
.and_then(|nodes| nodes.first().map(|n| n.id))
}
fn get_entity_key(graph: &GraphEngine, node_id: u64) -> Option<String> {
graph.get_node(node_id).ok().and_then(|node| {
if let Some(PropertyValue::String(key)) = node.properties.get("entity_key") {
Some(key.clone())
} else {
None
}
})
}
struct EdgeInfo {
target_key: String,
source_key: String,
edge_type: String,
signature: Option<Vec<u8>>,
sig_timestamp: Option<i64>,
capacity: Option<i64>,
}
fn get_outgoing_edges(graph: &GraphEngine, entity_key: &str) -> Vec<(String, String)> {
get_outgoing_edges_full(graph, entity_key)
.into_iter()
.map(|e| (e.target_key, e.edge_type))
.collect()
}
fn get_outgoing_edges_full(graph: &GraphEngine, entity_key: &str) -> Vec<EdgeInfo> {
let Some(node_id) = find_entity_node(graph, entity_key) else {
return Vec::new();
};
let mut result = Vec::new();
if let Ok(edges) = graph.edges_of(node_id, Direction::Outgoing) {
for edge in edges {
let target_id = if edge.from == node_id {
edge.to
} else {
edge.from
};
if let Some(target_key) = get_entity_key(graph, target_id) {
let signature = match edge.properties.get("vault_sig") {
Some(PropertyValue::Bytes(b)) => Some(b.clone()),
_ => None,
};
let sig_timestamp = match edge.properties.get("vault_sig_ts") {
Some(PropertyValue::Int(ts)) => Some(*ts),
_ => None,
};
let capacity = match edge.properties.get("vault_capacity") {
Some(PropertyValue::Int(c)) => Some(*c),
_ => None,
};
result.push(EdgeInfo {
target_key,
source_key: entity_key.to_string(),
edge_type: edge.edge_type.clone(),
signature,
sig_timestamp,
capacity,
});
}
}
}
result
}
fn get_incoming_edges(graph: &GraphEngine, entity_key: &str) -> Vec<(String, String)> {
let Some(node_id) = find_entity_node(graph, entity_key) else {
return Vec::new();
};
let mut result = Vec::new();
if let Ok(edges) = graph.edges_of(node_id, Direction::Incoming) {
for edge in edges {
let source_id = if edge.to == node_id {
edge.from
} else {
edge.to
};
if let Some(source_key) = get_entity_key(graph, source_id) {
result.push((source_key, edge.edge_type.clone()));
}
}
}
result
}
#[cfg(test)]
fn add_edge(graph: &GraphEngine, from_key: &str, to_key: &str, edge_type: &str) -> u64 {
let from_node = get_or_create_entity_node(graph, from_key);
let to_node = get_or_create_entity_node(graph, to_key);
graph
.create_edge(from_node, to_node, edge_type, HashMap::new(), true)
.unwrap_or(0)
}
const VAULT_ACCESS_PREFIX: &str = "VAULT_ACCESS";
const ALLOWED_TRAVERSAL_EDGES: &[&str] = &[
"VAULT_ACCESS",
"VAULT_ACCESS_READ",
"VAULT_ACCESS_WRITE",
"VAULT_ACCESS_ADMIN",
"MEMBER",
];
const MAX_BFS_DEPTH: usize = 32;
fn is_allowed_edge_type(edge_type: &str) -> bool {
ALLOWED_TRAVERSAL_EDGES
.iter()
.any(|&allowed| edge_type.starts_with(allowed))
}
impl AccessController {
pub fn check_path(graph: &GraphEngine, source: &str, target: &str) -> bool {
if source == target {
return true;
}
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
queue.push_back((source.to_string(), 0));
visited.insert(source.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= MAX_BFS_DEPTH {
continue;
}
for (to, edge_type) in get_outgoing_edges(graph, ¤t) {
if !is_allowed_edge_type(&edge_type) {
continue;
}
if to == target {
return true;
}
if !visited.contains(&to) {
visited.insert(to.clone());
queue.push_back((to, depth + 1));
}
}
}
false
}
pub fn get_direct_accessors(graph: &GraphEngine, target: &str) -> Vec<String> {
let mut accessors = Vec::new();
for (from, edge_type) in get_incoming_edges(graph, target) {
if edge_type.starts_with(VAULT_ACCESS_PREFIX) {
accessors.push(from);
}
}
accessors
}
pub fn get_permission_level(
graph: &GraphEngine,
source: &str,
target: &str,
) -> Option<Permission> {
if source == target {
return Some(Permission::Admin);
}
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
let mut best_permission: Option<Permission> = None;
queue.push_back((source.to_string(), 0));
visited.insert(source.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= MAX_BFS_DEPTH {
continue;
}
for (to, edge_type) in get_outgoing_edges(graph, ¤t) {
if !is_allowed_edge_type(&edge_type) {
continue;
}
if edge_type.starts_with(VAULT_ACCESS_PREFIX) {
if to == target {
if let Some(perm) = Permission::from_edge_type(&edge_type) {
best_permission = Some(match best_permission {
None => perm,
Some(existing) => Self::max_permission(existing, perm),
});
}
}
} else {
if !visited.contains(&to) {
visited.insert(to.clone());
queue.push_back((to, depth + 1));
}
}
}
}
best_permission
}
pub fn get_permission_level_verified(
graph: &GraphEngine,
source: &str,
target: &str,
signer: &EdgeSigner,
policy: &AttenuationPolicy,
) -> Option<Permission> {
if source == target {
return Some(Permission::Admin);
}
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
let mut best_permission: Option<Permission> = None;
queue.push_back((source.to_string(), 0));
visited.insert(source.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= policy.horizon {
continue;
}
for edge in get_outgoing_edges_full(graph, ¤t) {
if !is_allowed_edge_type(&edge.edge_type) {
continue;
}
if edge.edge_type.starts_with(VAULT_ACCESS_PREFIX) {
if edge.target_key == target {
if let (Some(sig), Some(ts)) = (&edge.signature, edge.sig_timestamp) {
if !signer.verify_edge(
&edge.source_key,
&edge.target_key,
&edge.edge_type,
ts,
sig,
) {
continue;
}
}
let total_hops = depth + 1;
if let Some(perm) = Permission::from_edge_type(&edge.edge_type) {
if let Some(attenuated) = policy.attenuate(perm, total_hops) {
let effective = match edge.capacity.and_then(Permission::from_level)
{
Some(cap) => Self::min_permission(attenuated, cap),
None => attenuated,
};
best_permission = Some(match best_permission {
None => effective,
Some(existing) => Self::max_permission(existing, effective),
});
}
}
}
} else if !visited.contains(&edge.target_key) {
visited.insert(edge.target_key.clone());
queue.push_back((edge.target_key, depth + 1));
}
}
}
best_permission
}
pub fn check_path_with_permission(
graph: &GraphEngine,
source: &str,
target: &str,
required: Permission,
) -> bool {
match Self::get_permission_level(graph, source, target) {
Some(perm) => perm.allows(required),
None => false,
}
}
pub fn check_path_with_permission_verified(
graph: &GraphEngine,
source: &str,
target: &str,
required: Permission,
signer: &EdgeSigner,
policy: &AttenuationPolicy,
) -> bool {
match Self::get_permission_level_verified(graph, source, target, signer, policy) {
Some(perm) => perm.allows(required),
None => false,
}
}
fn max_permission(a: Permission, b: Permission) -> Permission {
match (a, b) {
(Permission::Admin, _) | (_, Permission::Admin) => Permission::Admin,
(Permission::Write, _) | (_, Permission::Write) => Permission::Write,
(Permission::Read, Permission::Read) => Permission::Read,
}
}
fn min_permission(a: Permission, b: Permission) -> Permission {
match (a, b) {
(Permission::Read, _) | (_, Permission::Read) => Permission::Read,
(Permission::Write, _) | (_, Permission::Write) => Permission::Write,
(Permission::Admin, Permission::Admin) => Permission::Admin,
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn test_node_creation_and_lookup() {
let graph = GraphEngine::new();
let node_id = get_or_create_entity_node(&graph, "test:entity");
assert!(node_id > 0, "Node should be created with positive ID");
let found = find_entity_node(&graph, "test:entity");
assert_eq!(found, Some(node_id), "Should find the same node");
let node = graph.get_node(node_id).expect("Should get node");
let key = node.properties.get("entity_key");
assert_eq!(
key,
Some(&PropertyValue::String("test:entity".to_string())),
"Node should have correct entity_key property"
);
}
#[test]
fn test_edge_creation_and_lookup() {
let graph = GraphEngine::new();
let edge_id = add_edge(&graph, "from:a", "to:b", "TEST_EDGE");
assert!(edge_id > 0, "Edge should be created");
let outgoing = get_outgoing_edges(&graph, "from:a");
assert_eq!(outgoing.len(), 1, "Should have 1 outgoing edge");
assert_eq!(outgoing[0].0, "to:b", "Target should be to:b");
assert_eq!(outgoing[0].1, "TEST_EDGE", "Edge type should be TEST_EDGE");
}
#[test]
fn test_same_node() {
let graph = GraphEngine::new();
assert!(AccessController::check_path(
&graph,
"user:alice",
"user:alice"
));
}
#[test]
fn test_direct_path() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:api_key", "VAULT_ACCESS");
assert!(AccessController::check_path(
&graph,
"user:alice",
"secret:api_key"
));
assert!(!AccessController::check_path(
&graph,
"user:bob",
"secret:api_key"
));
}
#[test]
fn test_transitive_path() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:api_key", "VAULT_ACCESS");
assert!(AccessController::check_path(
&graph,
"user:alice",
"secret:api_key"
));
}
#[test]
fn test_no_path() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:one", "VAULT_ACCESS");
add_edge(&graph, "user:bob", "secret:two", "VAULT_ACCESS");
assert!(!AccessController::check_path(
&graph,
"user:alice",
"secret:two"
));
}
#[test]
fn test_cycle_handling() {
let graph = GraphEngine::new();
add_edge(&graph, "node:a", "node:b", "MEMBER");
add_edge(&graph, "node:b", "node:c", "MEMBER");
add_edge(&graph, "node:c", "node:a", "MEMBER");
assert!(AccessController::check_path(&graph, "node:a", "node:c"));
assert!(!AccessController::check_path(&graph, "node:a", "node:d"));
}
#[test]
fn test_long_path() {
let graph = GraphEngine::new();
for i in 0..10 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
assert!(AccessController::check_path(&graph, "node:0", "node:10"));
assert!(!AccessController::check_path(&graph, "node:10", "node:0"));
}
#[test]
fn test_disallowed_edge_type_blocked() {
let graph = GraphEngine::new();
add_edge(&graph, "node:a", "node:b", "RANDOM_EDGE");
assert!(!AccessController::check_path(&graph, "node:a", "node:b"));
}
#[test]
fn test_directional_path() {
let graph = GraphEngine::new();
add_edge(&graph, "node:a", "node:b", "MEMBER");
assert!(AccessController::check_path(&graph, "node:a", "node:b"));
assert!(!AccessController::check_path(&graph, "node:b", "node:a"));
}
#[test]
fn test_get_direct_accessors() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS");
add_edge(&graph, "user:bob", "secret:key", "VAULT_ACCESS");
add_edge(&graph, "user:carol", "secret:key", "OTHER_EDGE");
let accessors = AccessController::get_direct_accessors(&graph, "secret:key");
assert_eq!(accessors.len(), 2);
assert!(accessors.contains(&"user:alice".to_string()));
assert!(accessors.contains(&"user:bob".to_string()));
assert!(!accessors.contains(&"user:carol".to_string()));
}
#[test]
fn test_empty_graph() {
let graph = GraphEngine::new();
assert!(!AccessController::check_path(
&graph,
"user:alice",
"secret:key"
));
}
#[test]
fn test_concurrent_access_check() {
use std::thread;
let graph = Arc::new(GraphEngine::new());
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS");
let handles: Vec<_> = (0..4)
.map(|_| {
let graph = Arc::clone(&graph);
thread::spawn(move || {
for _ in 0..100 {
let result =
AccessController::check_path(&graph, "user:alice", "secret:key");
assert!(result);
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_permission_level_direct_read() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_READ");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Read));
}
#[test]
fn test_permission_level_direct_write() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_WRITE");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Write));
}
#[test]
fn test_permission_level_direct_admin() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_ADMIN");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Admin));
}
#[test]
fn test_permission_level_backward_compat() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Admin));
}
#[test]
fn test_permission_level_transitive_minimum() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:key", "VAULT_ACCESS_READ");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Read));
}
#[test]
fn test_permission_level_best_of_multiple_paths() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_READ");
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:key", "VAULT_ACCESS_ADMIN");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Admin));
}
#[test]
fn test_permission_level_no_path() {
let graph = GraphEngine::new();
add_edge(&graph, "user:bob", "secret:key", "VAULT_ACCESS_READ");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, None);
}
#[test]
fn test_check_path_with_permission_read_ok() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_READ");
assert!(AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Read
));
}
#[test]
fn test_check_path_with_permission_read_denied_write() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_READ");
assert!(!AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Write
));
}
#[test]
fn test_check_path_with_permission_write_allows_read() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_WRITE");
assert!(AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Read
));
}
#[test]
fn test_check_path_with_permission_admin_allows_all() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_ADMIN");
assert!(AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Read
));
assert!(AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Write
));
assert!(AccessController::check_path_with_permission(
&graph,
"user:alice",
"secret:key",
Permission::Admin
));
}
#[test]
fn test_get_direct_accessors_with_permission_levels() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "VAULT_ACCESS_READ");
add_edge(&graph, "user:bob", "secret:key", "VAULT_ACCESS_WRITE");
add_edge(&graph, "user:carol", "secret:key", "VAULT_ACCESS_ADMIN");
let accessors = AccessController::get_direct_accessors(&graph, "secret:key");
assert_eq!(accessors.len(), 3);
assert!(accessors.contains(&"user:alice".to_string()));
assert!(accessors.contains(&"user:bob".to_string()));
assert!(accessors.contains(&"user:carol".to_string()));
}
#[test]
fn test_member_edge_direct_to_secret_no_permission() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "secret:key", "MEMBER");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, None);
}
#[test]
fn test_member_chain_without_vault_access_no_permission() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:key", "MEMBER");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, None);
}
#[test]
fn test_member_traversal_to_vault_access_grants_permission() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:key", "VAULT_ACCESS_WRITE");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Write));
}
#[test]
fn test_member_with_mixed_access_paths() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:team1", "MEMBER");
add_edge(&graph, "team:team1", "secret:key", "MEMBER");
add_edge(&graph, "user:alice", "team:team2", "MEMBER");
add_edge(&graph, "team:team2", "secret:key", "VAULT_ACCESS_READ");
let perm = AccessController::get_permission_level(&graph, "user:alice", "secret:key");
assert_eq!(perm, Some(Permission::Read));
}
#[test]
fn test_check_path_still_works_with_member() {
let graph = GraphEngine::new();
add_edge(&graph, "user:alice", "team:devs", "MEMBER");
add_edge(&graph, "team:devs", "secret:key", "MEMBER");
assert!(AccessController::check_path(
&graph,
"user:alice",
"secret:key"
));
assert_eq!(
AccessController::get_permission_level(&graph, "user:alice", "secret:key"),
None
);
}
#[test]
fn test_check_path_depth_limit_exceeded() {
let graph = GraphEngine::new();
for i in 0..35 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
assert!(!AccessController::check_path(&graph, "node:0", "node:35"));
}
#[test]
fn test_check_path_within_depth_limit() {
let graph = GraphEngine::new();
for i in 0..30 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
assert!(AccessController::check_path(&graph, "node:0", "node:30"));
}
#[test]
fn test_get_permission_level_depth_limit_exceeded() {
let graph = GraphEngine::new();
for i in 0..35 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
add_edge(&graph, "node:35", "secret:key", "VAULT_ACCESS_WRITE");
assert_eq!(
AccessController::get_permission_level(&graph, "node:0", "secret:key"),
None
);
}
#[test]
fn test_get_permission_level_within_depth_limit() {
let graph = GraphEngine::new();
for i in 0..30 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
add_edge(&graph, "node:30", "secret:key", "VAULT_ACCESS_WRITE");
assert_eq!(
AccessController::get_permission_level(&graph, "node:0", "secret:key"),
Some(Permission::Write)
);
}
#[test]
fn test_check_path_at_exact_boundary() {
let graph = GraphEngine::new();
for i in 0..32 {
add_edge(
&graph,
&format!("node:{i}"),
&format!("node:{}", i + 1),
"MEMBER",
);
}
assert!(AccessController::check_path(&graph, "node:0", "node:32"));
add_edge(&graph, "node:32", "node:33", "MEMBER");
assert!(!AccessController::check_path(&graph, "node:0", "node:33"));
}
}