use std::collections::{HashMap, HashSet, VecDeque};
use std::cmp::Ordering;
use graph_engine::algorithms::{SimilarityConfig, TriangleConfig};
use graph_engine::{Direction, PropertyValue};
use crate::access::AccessController;
use crate::vault::Vault;
use crate::Permission;
const MAX_BFS_DEPTH: usize = 32;
const VAULT_ACCESS_PREFIX: &str = "VAULT_ACCESS";
const ALLOWED_TRAVERSAL_EDGES: &[&str] = &[
"VAULT_ACCESS",
"VAULT_ACCESS_READ",
"VAULT_ACCESS_WRITE",
"VAULT_ACCESS_ADMIN",
"MEMBER",
];
fn is_allowed_edge_type(edge_type: &str) -> bool {
ALLOWED_TRAVERSAL_EDGES
.iter()
.any(|&allowed| edge_type.starts_with(allowed))
}
#[derive(Debug, Clone)]
pub struct AccessHop {
pub entity: String,
pub edge_type: String,
pub permission: Option<Permission>,
pub hop_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenialReason {
NoPath,
InsufficientPermission {
highest: Permission,
required: Permission,
},
AttenuatedBeyondThreshold {
original: Permission,
attenuated_to: Option<Permission>,
},
TamperedEdge {
at_hop: usize,
},
}
#[derive(Debug, Clone)]
pub struct AccessExplanation {
pub entity: String,
pub secret: String,
pub granted: bool,
pub effective_permission: Option<Permission>,
pub paths: Vec<Vec<AccessHop>>,
pub denial_reason: Option<DenialReason>,
}
#[derive(Debug, Clone)]
pub struct ReachableSecret {
pub secret_name: String,
pub permission: Permission,
pub hop_count: usize,
pub path_count: usize,
}
#[derive(Debug, Clone)]
pub struct BlastRadius {
pub entity: String,
pub secrets: Vec<ReachableSecret>,
pub total_secrets: usize,
}
#[derive(Debug, Clone)]
pub struct NewAccess {
pub entity: String,
pub secret: String,
pub permission: Permission,
}
#[derive(Debug, Clone)]
pub struct SimulationResult {
pub target_entity: String,
pub secret: String,
pub requested_permission: Permission,
pub new_accesses: Vec<NewAccess>,
pub total_affected: usize,
}
#[derive(Debug, Clone)]
pub struct AccessCycle {
pub entities: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SinglePointOfFailure {
pub entity: String,
pub secrets_affected: usize,
pub entities_affected: usize,
}
#[derive(Debug, Clone)]
pub struct OverPrivilegedEntity {
pub entity: String,
pub pagerank_score: f64,
pub reachable_secrets: usize,
pub admin_count: usize,
}
#[derive(Debug, Clone)]
pub struct SecurityAuditReport {
pub cycles: Vec<AccessCycle>,
pub single_points_of_failure: Vec<SinglePointOfFailure>,
pub over_privileged: Vec<OverPrivilegedEntity>,
pub total_entities: usize,
pub total_secrets: usize,
pub total_edges: usize,
}
#[derive(Debug, Clone)]
pub struct CriticalEntity {
pub entity: String,
pub is_single_point_of_failure: bool,
pub secrets_solely_dependent: usize,
pub total_reachable_secrets: usize,
pub pagerank_score: f64,
}
#[derive(Debug, Clone)]
pub struct PrivilegeAnalysis {
pub entity: String,
pub pagerank_score: f64,
pub reachable_secrets: usize,
pub admin_count: usize,
pub write_count: usize,
pub read_count: usize,
pub privilege_score: f64,
}
#[derive(Debug, Clone)]
pub struct PrivilegeAnalysisReport {
pub entities: Vec<PrivilegeAnalysis>,
pub mean_privilege_score: f64,
pub max_privilege_score: f64,
}
#[derive(Debug, Clone)]
pub struct DelegationAnomalyScore {
pub entity: String,
pub secret: String,
pub jaccard: f64,
pub adamic_adar: f64,
pub anomaly_score: f64,
}
#[derive(Debug, Clone)]
pub struct InferredRole {
pub role_id: u64,
pub members: Vec<String>,
pub common_secrets: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RoleInferenceResult {
pub roles: Vec<InferredRole>,
pub modularity: f64,
pub unassigned: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct EntityTrustScore {
pub entity: String,
pub triangle_count: usize,
pub clustering_coefficient: f64,
pub trust_score: f64,
}
#[derive(Debug, Clone)]
pub struct TrustTransitivityReport {
pub entities: Vec<EntityTrustScore>,
pub global_clustering: f64,
pub total_triangles: usize,
}
#[derive(Debug, Clone)]
pub struct RiskContributor {
pub secret: String,
pub permission: Permission,
pub hop_count: usize,
}
#[derive(Debug, Clone)]
pub struct EntityRiskScore {
pub entity: String,
pub eigenvector_score: f64,
pub reachable_admin_secrets: usize,
pub risk_contributors: Vec<RiskContributor>,
pub risk_score: f64,
}
#[derive(Debug, Clone)]
pub struct RiskPropagationReport {
pub entities: Vec<EntityRiskScore>,
pub mean_risk: f64,
pub max_risk: f64,
}
struct IntelEdgeInfo {
target_key: String,
source_key: String,
edge_type: String,
signature: Option<Vec<u8>>,
sig_timestamp: Option<i64>,
capacity: Option<i64>,
}
fn get_outgoing_edges_full(vault: &Vault, entity_key: &str) -> Vec<IntelEdgeInfo> {
let Some(node_id) = vault.find_entity_node(entity_key) else {
return Vec::new();
};
let mut result = Vec::new();
if let Ok(edges) = vault.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) = vault.node_entity_key(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(IntelEdgeInfo {
target_key,
source_key: entity_key.to_string(),
edge_type: edge.edge_type.clone(),
signature,
sig_timestamp,
capacity,
});
}
}
}
result
}
fn collect_vault_secrets(vault: &Vault) -> Vec<(String, String)> {
let mut secrets = Vec::new();
for vault_key in vault.store.scan(Vault::PREFIX) {
if let Ok(tensor) = vault.store.get(&vault_key) {
if let Some(name) = vault.decrypt_key_name(&tensor) {
let node_key = vault.secret_node_key(&name);
secrets.push((node_key, name));
}
}
}
secrets
}
fn collect_entity_keys(vault: &Vault) -> Vec<String> {
let mut entities = Vec::new();
let secrets_prefix = "vault_secret:";
let all_edges = vault.graph.all_edges();
let mut seen = HashSet::new();
for edge in all_edges {
for node_id in [edge.from, edge.to] {
if seen.insert(node_id) {
if let Some(key) = vault.node_entity_key(node_id) {
if !key.starts_with(secrets_prefix) {
entities.push(key);
}
}
}
}
}
entities
}
fn bfs_reachable_secrets(
vault: &Vault,
entity: &str,
) -> HashMap<String, (Permission, usize, usize)> {
let mut result: HashMap<String, (Permission, usize, usize)> = HashMap::new();
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
queue.push_back((entity.to_string(), 0));
visited.insert(entity.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= vault.attenuation.horizon {
continue;
}
for edge in get_outgoing_edges_full(vault, ¤t) {
if !is_allowed_edge_type(&edge.edge_type) {
continue;
}
if edge.edge_type.starts_with(VAULT_ACCESS_PREFIX) {
if let (Some(sig), Some(ts)) = (&edge.signature, edge.sig_timestamp) {
if !vault.edge_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) = vault.attenuation.attenuate(perm, total_hops) {
let effective = match edge.capacity.and_then(Permission::from_level) {
Some(cap) => min_permission(attenuated, cap),
None => attenuated,
};
let entry = result.entry(edge.target_key.clone()).or_insert((
Permission::Read,
usize::MAX,
0,
));
if total_hops < entry.1 {
*entry = (effective, total_hops, 1);
} else if total_hops == entry.1 {
entry.2 += 1;
entry.0 = max_permission(entry.0, effective);
} else if effective.to_level() > entry.0.to_level() {
entry.0 = effective;
}
}
}
} else {
if !visited.contains(&edge.target_key) {
visited.insert(edge.target_key.clone());
queue.push_back((edge.target_key, depth + 1));
}
}
}
}
result
}
fn bfs_member_reachable(vault: &Vault, entity: &str) -> HashSet<String> {
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
queue.push_back((entity.to_string(), 0));
visited.insert(entity.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= MAX_BFS_DEPTH {
continue;
}
if let Some(node_id) = vault.find_entity_node(¤t) {
if let Ok(edges) = vault.graph.edges_of(node_id, Direction::Incoming) {
for edge in edges {
if edge.edge_type == "MEMBER" {
let source_id = if edge.to == node_id {
edge.from
} else {
edge.to
};
if let Some(source_key) = vault.node_entity_key(source_id) {
if visited.insert(source_key.clone()) {
queue.push_back((source_key, depth + 1));
}
}
}
}
}
}
}
visited
}
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,
}
}
struct ExplainBfsResult {
paths: Vec<Vec<AccessHop>>,
best_permission: Option<Permission>,
tampered_hop: Option<usize>,
highest_raw: Option<Permission>,
}
fn explain_bfs(vault: &Vault, entity: &str, secret_node: &str) -> ExplainBfsResult {
let mut paths: Vec<Vec<AccessHop>> = Vec::new();
let mut best_permission: Option<Permission> = None;
let mut tampered_hop: Option<usize> = None;
let mut highest_raw: Option<Permission> = None;
let mut queue: VecDeque<(String, usize, Vec<AccessHop>)> = VecDeque::new();
let mut visited = HashSet::new();
queue.push_back((entity.to_string(), 0, Vec::new()));
visited.insert(entity.to_string());
while let Some((current, depth, path)) = queue.pop_front() {
if depth >= vault.attenuation.horizon {
continue;
}
for edge in get_outgoing_edges_full(vault, ¤t) {
if !is_allowed_edge_type(&edge.edge_type) {
continue;
}
if edge.edge_type.starts_with(VAULT_ACCESS_PREFIX) {
if edge.target_key == secret_node {
let total_hops = depth + 1;
let perm_from_edge = Permission::from_edge_type(&edge.edge_type);
if !verify_edge_sig(vault, &edge) {
tampered_hop = Some(total_hops);
continue;
}
let mut hop_path = path.clone();
hop_path.push(AccessHop {
entity: edge.target_key.clone(),
edge_type: edge.edge_type.clone(),
permission: perm_from_edge,
hop_index: total_hops,
});
if let Some(perm) = perm_from_edge {
highest_raw = Some(highest_raw.map_or(perm, |e| max_permission(e, perm)));
if let Some(eff) = compute_effective(vault, &edge, perm, total_hops) {
best_permission =
Some(best_permission.map_or(eff, |e| max_permission(e, eff)));
}
}
paths.push(hop_path);
}
} else if !visited.contains(&edge.target_key) {
visited.insert(edge.target_key.clone());
let mut new_path = path.clone();
new_path.push(AccessHop {
entity: edge.target_key.clone(),
edge_type: edge.edge_type.clone(),
permission: None,
hop_index: depth + 1,
});
queue.push_back((edge.target_key, depth + 1, new_path));
}
}
}
ExplainBfsResult {
paths,
best_permission,
tampered_hop,
highest_raw,
}
}
fn verify_edge_sig(vault: &Vault, edge: &IntelEdgeInfo) -> bool {
if let (Some(sig), Some(ts)) = (&edge.signature, edge.sig_timestamp) {
vault
.edge_signer
.verify_edge(&edge.source_key, &edge.target_key, &edge.edge_type, ts, sig)
} else {
true
}
}
fn compute_effective(
vault: &Vault,
edge: &IntelEdgeInfo,
perm: Permission,
hops: usize,
) -> Option<Permission> {
vault.attenuation.attenuate(perm, hops).map(|attenuated| {
match edge.capacity.and_then(Permission::from_level) {
Some(cap) => min_permission(attenuated, cap),
None => attenuated,
}
})
}
pub fn explain_access(vault: &Vault, entity: &str, secret: &str) -> AccessExplanation {
let secret_node = vault.secret_node_key(secret);
if vault.find_entity_node(entity).is_none() {
return AccessExplanation {
entity: entity.to_string(),
secret: secret.to_string(),
granted: false,
effective_permission: None,
paths: Vec::new(),
denial_reason: Some(DenialReason::NoPath),
};
}
let bfs = explain_bfs(vault, entity, &secret_node);
let denial_reason = if bfs.paths.is_empty() {
if bfs.tampered_hop.is_some() {
bfs.tampered_hop
.map(|at_hop| DenialReason::TamperedEdge { at_hop })
} else {
Some(DenialReason::NoPath)
}
} else if bfs.best_permission.is_none() {
Some(DenialReason::AttenuatedBeyondThreshold {
original: bfs.highest_raw.unwrap_or(Permission::Read),
attenuated_to: None,
})
} else {
None
};
AccessExplanation {
entity: entity.to_string(),
secret: secret.to_string(),
granted: bfs.best_permission.is_some(),
effective_permission: bfs.best_permission,
paths: bfs.paths,
denial_reason,
}
}
pub fn blast_radius(vault: &Vault, entity: &str) -> BlastRadius {
let reachable = bfs_reachable_secrets(vault, entity);
let all_secrets = collect_vault_secrets(vault);
let node_to_name: HashMap<String, String> = all_secrets.into_iter().collect();
let mut secrets: Vec<ReachableSecret> = Vec::new();
for (node_key, (perm, hops, paths)) in &reachable {
if let Some(name) = node_to_name.get(node_key) {
secrets.push(ReachableSecret {
secret_name: name.clone(),
permission: *perm,
hop_count: *hops,
path_count: *paths,
});
}
}
secrets.sort_by(|a, b| {
b.permission
.to_level()
.cmp(&a.permission.to_level())
.then_with(|| a.hop_count.cmp(&b.hop_count))
.then_with(|| a.secret_name.cmp(&b.secret_name))
});
let total = secrets.len();
BlastRadius {
entity: entity.to_string(),
secrets,
total_secrets: total,
}
}
pub fn simulate_grant(
vault: &Vault,
entity: &str,
secret: &str,
permission: Permission,
) -> SimulationResult {
let secret_node = vault.secret_node_key(secret);
let reachable_entities = bfs_member_reachable(vault, entity);
let mut new_accesses = Vec::new();
for ent in &reachable_entities {
let current_perm = AccessController::get_permission_level_verified(
&vault.graph,
ent,
&secret_node,
&vault.edge_signer,
&vault.attenuation,
);
let would_gain = match current_perm {
None => true,
Some(existing) => permission.to_level() > existing.to_level(),
};
if would_gain {
new_accesses.push(NewAccess {
entity: ent.clone(),
secret: secret.to_string(),
permission,
});
}
}
let total = new_accesses.len();
SimulationResult {
target_entity: entity.to_string(),
secret: secret.to_string(),
requested_permission: permission,
new_accesses,
total_affected: total,
}
}
pub fn security_audit(vault: &Vault) -> SecurityAuditReport {
let all_entities = collect_entity_keys(vault);
let all_secrets = collect_vault_secrets(vault);
let edge_count = vault.graph.all_edges().len();
let cycles = detect_cycles(vault, &all_entities);
let spofs = detect_spofs(vault, &all_entities, &all_secrets);
let over_privileged = detect_over_privileged(vault, &all_entities);
SecurityAuditReport {
cycles,
single_points_of_failure: spofs,
over_privileged,
total_entities: all_entities.len(),
total_secrets: all_secrets.len(),
total_edges: edge_count,
}
}
pub fn find_critical_entities(vault: &Vault) -> Vec<CriticalEntity> {
let all_entities = collect_entity_keys(vault);
let all_secrets = collect_vault_secrets(vault);
let spofs = detect_spofs(vault, &all_entities, &all_secrets);
let spof_set: HashSet<String> = spofs.iter().map(|s| s.entity.clone()).collect();
let spof_map: HashMap<String, &SinglePointOfFailure> =
spofs.iter().map(|s| (s.entity.clone(), s)).collect();
let pr_scores = vault
.graph
.pagerank(None)
.map_or_else(|_| HashMap::new(), |pr| pr.scores);
let mut results = Vec::new();
for ent in &all_entities {
if ent == Vault::ROOT {
continue;
}
let reachable = bfs_reachable_secrets(vault, ent);
let total_reachable = reachable.len();
if total_reachable == 0 {
continue;
}
let node_id = vault.find_entity_node(ent);
let pr_score = node_id
.and_then(|id| pr_scores.get(&id).copied())
.unwrap_or(0.0);
let is_spof = spof_set.contains(ent);
let solely_dependent = spof_map.get(ent).map_or(0, |s| s.secrets_affected);
results.push(CriticalEntity {
entity: ent.clone(),
is_single_point_of_failure: is_spof,
secrets_solely_dependent: solely_dependent,
total_reachable_secrets: total_reachable,
pagerank_score: pr_score,
});
}
results.sort_by(|a, b| {
b.is_single_point_of_failure
.cmp(&a.is_single_point_of_failure)
.then_with(|| {
#[allow(clippy::cast_precision_loss)]
let score_a = a.pagerank_score * a.total_reachable_secrets as f64;
#[allow(clippy::cast_precision_loss)]
let score_b = b.pagerank_score * b.total_reachable_secrets as f64;
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
results
}
struct TarjanState<'a> {
vault: &'a Vault,
index_counter: usize,
stack: Vec<String>,
on_stack: HashSet<String>,
indices: HashMap<String, usize>,
lowlinks: HashMap<String, usize>,
sccs: Vec<Vec<String>>,
}
impl<'a> TarjanState<'a> {
fn new(vault: &'a Vault) -> Self {
Self {
vault,
index_counter: 0,
stack: Vec::new(),
on_stack: HashSet::new(),
indices: HashMap::new(),
lowlinks: HashMap::new(),
sccs: Vec::new(),
}
}
fn visit(&mut self, v: &str) {
self.indices.insert(v.to_string(), self.index_counter);
self.lowlinks.insert(v.to_string(), self.index_counter);
self.index_counter += 1;
self.stack.push(v.to_string());
self.on_stack.insert(v.to_string());
let successors: Vec<String> = get_outgoing_edges_full(self.vault, v)
.into_iter()
.filter(|e| is_allowed_edge_type(&e.edge_type))
.map(|e| e.target_key)
.collect();
for w in &successors {
if !self.indices.contains_key(w) {
self.visit(w);
let lw = *self.lowlinks.get(w).unwrap_or(&usize::MAX);
let lv = *self.lowlinks.get(v).unwrap_or(&usize::MAX);
self.lowlinks.insert(v.to_string(), lv.min(lw));
} else if self.on_stack.contains(w) {
let iw = *self.indices.get(w).unwrap_or(&usize::MAX);
let lv = *self.lowlinks.get(v).unwrap_or(&usize::MAX);
self.lowlinks.insert(v.to_string(), lv.min(iw));
}
}
if self.lowlinks.get(v) == self.indices.get(v) {
let mut scc = Vec::new();
while let Some(w) = self.stack.pop() {
self.on_stack.remove(&w);
scc.push(w.clone());
if w == v {
break;
}
}
if scc.len() > 1 {
self.sccs.push(scc);
}
}
}
}
fn detect_cycles(vault: &Vault, entities: &[String]) -> Vec<AccessCycle> {
let mut state = TarjanState::new(vault);
for entity in entities {
if !state.indices.contains_key(entity) {
state.visit(entity);
}
}
state
.sccs
.into_iter()
.map(|entities| AccessCycle { entities })
.collect()
}
fn detect_spofs(
vault: &Vault,
entities: &[String],
secrets: &[(String, String)],
) -> Vec<SinglePointOfFailure> {
if secrets.is_empty() {
return Vec::new();
}
let baseline_reachable = bfs_reachable_secrets(vault, Vault::ROOT);
let baseline_secrets: HashSet<&String> = baseline_reachable.keys().collect();
let mut spofs = Vec::new();
for entity in entities {
if entity == Vault::ROOT {
continue;
}
let reachable_without = bfs_reachable_secrets_excluding(vault, Vault::ROOT, entity);
let remaining: HashSet<&String> = reachable_without.keys().collect();
let lost_secrets: HashSet<&&String> = baseline_secrets.difference(&remaining).collect();
if lost_secrets.is_empty() {
continue;
}
let other_entities: Vec<&String> = entities
.iter()
.filter(|e| *e != entity && *e != Vault::ROOT)
.collect();
let mut entities_affected = 0;
for other in &other_entities {
let their_reachable = bfs_reachable_secrets_excluding(vault, other, entity);
let their_remaining: HashSet<&String> = their_reachable.keys().collect();
let their_original = bfs_reachable_secrets(vault, other);
let their_baseline: HashSet<&String> = their_original.keys().collect();
if their_baseline.difference(&their_remaining).next().is_some() {
entities_affected += 1;
}
}
spofs.push(SinglePointOfFailure {
entity: entity.clone(),
secrets_affected: lost_secrets.len(),
entities_affected,
});
}
spofs.sort_by(|a, b| b.secrets_affected.cmp(&a.secrets_affected));
spofs
}
fn bfs_reachable_secrets_excluding(
vault: &Vault,
start: &str,
exclude: &str,
) -> HashMap<String, (Permission, usize, usize)> {
let mut result: HashMap<String, (Permission, usize, usize)> = HashMap::new();
let mut visited = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
queue.push_back((start.to_string(), 0));
visited.insert(start.to_string());
visited.insert(exclude.to_string());
while let Some((current, depth)) = queue.pop_front() {
if depth >= vault.attenuation.horizon {
continue;
}
for edge in get_outgoing_edges_full(vault, ¤t) {
if !is_allowed_edge_type(&edge.edge_type) {
continue;
}
if edge.target_key == exclude {
continue;
}
if edge.edge_type.starts_with(VAULT_ACCESS_PREFIX) {
if let Some(perm) = Permission::from_edge_type(&edge.edge_type) {
let total_hops = depth + 1;
if let Some(attenuated) = vault.attenuation.attenuate(perm, total_hops) {
let effective = match edge.capacity.and_then(Permission::from_level) {
Some(cap) => min_permission(attenuated, cap),
None => attenuated,
};
let entry = result.entry(edge.target_key.clone()).or_insert((
Permission::Read,
usize::MAX,
0,
));
if total_hops < entry.1 {
*entry = (effective, total_hops, 1);
} else if total_hops == entry.1 {
entry.2 += 1;
entry.0 = max_permission(entry.0, effective);
}
}
}
} else if !visited.contains(&edge.target_key) {
visited.insert(edge.target_key.clone());
queue.push_back((edge.target_key, depth + 1));
}
}
}
result
}
fn detect_over_privileged(vault: &Vault, entities: &[String]) -> Vec<OverPrivilegedEntity> {
let pr_scores = vault
.graph
.pagerank(None)
.map_or_else(|_| HashMap::new(), |pr| pr.scores);
let mut results = Vec::new();
for entity in entities {
if entity == Vault::ROOT {
continue;
}
let reachable = bfs_reachable_secrets(vault, entity);
let reachable_count = reachable.len();
if reachable_count == 0 {
continue;
}
let admin_count = get_outgoing_edges_full(vault, entity)
.iter()
.filter(|e| e.edge_type.ends_with("_ADMIN"))
.count();
let node_id = vault.find_entity_node(entity);
let pr_score = node_id
.and_then(|id| pr_scores.get(&id).copied())
.unwrap_or(0.0);
results.push(OverPrivilegedEntity {
entity: entity.clone(),
pagerank_score: pr_score,
reachable_secrets: reachable_count,
admin_count,
});
}
results.sort_by(|a, b| {
b.reachable_secrets
.cmp(&a.reachable_secrets)
.then_with(|| b.admin_count.cmp(&a.admin_count))
});
results
}
#[allow(clippy::cast_precision_loss)]
pub fn privilege_analysis(vault: &Vault) -> PrivilegeAnalysisReport {
let entities = collect_entity_keys(vault);
let pr_scores = vault
.graph
.pagerank(None)
.map_or_else(|_| HashMap::new(), |pr| pr.scores);
let mut analyses = Vec::new();
for ent in &entities {
if ent == Vault::ROOT {
continue;
}
let reachable = bfs_reachable_secrets(vault, ent);
let reachable_count = reachable.len();
let mut admin_count = 0usize;
let mut write_count = 0usize;
let mut read_count = 0usize;
for (perm, _, _) in reachable.values() {
match perm {
Permission::Admin => admin_count += 1,
Permission::Write => write_count += 1,
Permission::Read => read_count += 1,
}
}
let node_id = vault.find_entity_node(ent);
let pr_score = node_id
.and_then(|id| pr_scores.get(&id).copied())
.unwrap_or(0.0);
let privilege_score = pr_score * reachable_count as f64;
analyses.push(PrivilegeAnalysis {
entity: ent.clone(),
pagerank_score: pr_score,
reachable_secrets: reachable_count,
admin_count,
write_count,
read_count,
privilege_score,
});
}
analyses.sort_by(|a, b| {
b.privilege_score
.partial_cmp(&a.privilege_score)
.unwrap_or(Ordering::Equal)
});
let max_privilege_score = analyses.first().map_or(0.0, |a| a.privilege_score);
let sum: f64 = analyses.iter().map(|a| a.privilege_score).sum();
let mean_privilege_score = if analyses.is_empty() {
0.0
} else {
sum / analyses.len() as f64
};
PrivilegeAnalysisReport {
entities: analyses,
mean_privilege_score,
max_privilege_score,
}
}
pub fn delegation_anomaly_scores(vault: &Vault) -> Vec<DelegationAnomalyScore> {
let entities = collect_entity_keys(vault);
let config = SimilarityConfig {
edge_type: None,
direction: Direction::Both,
};
let mut scores = Vec::new();
for ent in &entities {
if ent == Vault::ROOT {
continue;
}
for edge in get_outgoing_edges_full(vault, ent) {
if !edge.edge_type.starts_with(VAULT_ACCESS_PREFIX) {
continue;
}
let Some(entity_id) = vault.find_entity_node(ent) else {
continue;
};
let Some(secret_id) = vault.find_entity_node(&edge.target_key) else {
continue;
};
let jaccard = vault
.graph
.jaccard_similarity(entity_id, secret_id, &config)
.unwrap_or(0.0);
let adamic = vault
.graph
.adamic_adar(entity_id, secret_id, &config)
.unwrap_or(0.0);
let anomaly_score = 1.0 - jaccard;
if anomaly_score > 0.5 {
let secret_name = edge
.target_key
.strip_prefix("vault_secret:")
.unwrap_or(&edge.target_key)
.to_string();
scores.push(DelegationAnomalyScore {
entity: ent.clone(),
secret: secret_name,
jaccard,
adamic_adar: adamic,
anomaly_score,
});
}
}
}
scores.sort_by(|a, b| {
b.anomaly_score
.partial_cmp(&a.anomaly_score)
.unwrap_or(Ordering::Equal)
});
scores
}
pub fn infer_roles(vault: &Vault) -> RoleInferenceResult {
let Ok(community_result) = vault.graph.louvain_communities(None) else {
return RoleInferenceResult {
roles: Vec::new(),
modularity: 0.0,
unassigned: Vec::new(),
};
};
let modularity = community_result.modularity.unwrap_or(0.0);
let secrets_prefix = "vault_secret:";
let mut community_entities: HashMap<u64, Vec<String>> = HashMap::new();
for (&node_id, &comm_id) in &community_result.communities {
if let Some(key) = vault.node_entity_key(node_id) {
if !key.starts_with(secrets_prefix) && key != Vault::ROOT {
community_entities.entry(comm_id).or_default().push(key);
}
}
}
let mut roles = Vec::new();
let mut unassigned = Vec::new();
for (comm_id, members) in &community_entities {
if members.len() < 2 {
unassigned.extend(members.clone());
continue;
}
let mut common: Option<HashSet<String>> = None;
for member in members {
let reachable = bfs_reachable_secrets(vault, member);
let secret_keys: HashSet<String> = reachable.keys().cloned().collect();
common = Some(match common {
Some(existing) => existing.intersection(&secret_keys).cloned().collect(),
None => secret_keys,
});
}
let common_secrets: Vec<String> = common
.unwrap_or_default()
.into_iter()
.map(|k| k.strip_prefix(secrets_prefix).unwrap_or(&k).to_string())
.collect();
roles.push(InferredRole {
role_id: *comm_id,
members: members.clone(),
common_secrets,
});
}
roles.sort_by(|a, b| b.members.len().cmp(&a.members.len()));
RoleInferenceResult {
roles,
modularity,
unassigned,
}
}
#[allow(clippy::cast_precision_loss)]
pub fn trust_transitivity(vault: &Vault) -> TrustTransitivityReport {
let config = TriangleConfig {
edge_type: None,
undirected: true,
};
let Ok(tri_result) = vault.graph.count_triangles(&config) else {
return TrustTransitivityReport {
entities: Vec::new(),
global_clustering: 0.0,
total_triangles: 0,
};
};
let entities = collect_entity_keys(vault);
let mut trust_scores = Vec::new();
for ent in &entities {
if ent == Vault::ROOT {
continue;
}
let Some(node_id) = vault.find_entity_node(ent) else {
continue;
};
let triangles = tri_result
.node_triangles
.get(&node_id)
.copied()
.unwrap_or(0);
let clustering = tri_result
.local_clustering
.get(&node_id)
.copied()
.unwrap_or(0.0);
let trust_score = clustering * (1.0 + (triangles as f64).ln_1p());
trust_scores.push(EntityTrustScore {
entity: ent.clone(),
triangle_count: triangles,
clustering_coefficient: clustering,
trust_score,
});
}
trust_scores.sort_by(|a, b| {
b.trust_score
.partial_cmp(&a.trust_score)
.unwrap_or(Ordering::Equal)
});
TrustTransitivityReport {
entities: trust_scores,
global_clustering: tri_result.global_clustering,
total_triangles: tri_result.triangle_count,
}
}
#[allow(clippy::cast_precision_loss)]
pub fn risk_propagation(vault: &Vault) -> RiskPropagationReport {
let eig_scores = vault
.graph
.eigenvector_centrality(None)
.map_or_else(|_| HashMap::new(), |r| r.scores);
let entities = collect_entity_keys(vault);
let all_secrets = collect_vault_secrets(vault);
let node_to_name: HashMap<String, String> = all_secrets.into_iter().collect();
let mut risk_scores = Vec::new();
for ent in &entities {
if ent == Vault::ROOT {
continue;
}
let node_id = vault.find_entity_node(ent);
let eig_score = node_id
.and_then(|id| eig_scores.get(&id).copied())
.unwrap_or(0.0);
let reachable = bfs_reachable_secrets(vault, ent);
let mut contributors = Vec::new();
let mut admin_count = 0usize;
for (secret_key, (perm, hops, _)) in &reachable {
if *perm == Permission::Admin {
admin_count += 1;
let secret_name = node_to_name.get(secret_key).cloned().unwrap_or_else(|| {
secret_key
.strip_prefix("vault_secret:")
.unwrap_or(secret_key)
.to_string()
});
contributors.push(RiskContributor {
secret: secret_name,
permission: *perm,
hop_count: *hops,
});
}
}
let risk_score = eig_score * (1.0 + admin_count as f64);
risk_scores.push(EntityRiskScore {
entity: ent.clone(),
eigenvector_score: eig_score,
reachable_admin_secrets: admin_count,
risk_contributors: contributors,
risk_score,
});
}
risk_scores.sort_by(|a, b| {
b.risk_score
.partial_cmp(&a.risk_score)
.unwrap_or(Ordering::Equal)
});
let max_risk = risk_scores.first().map_or(0.0, |r| r.risk_score);
let sum: f64 = risk_scores.iter().map(|r| r.risk_score).sum();
let mean_risk = if risk_scores.is_empty() {
0.0
} else {
sum / risk_scores.len() as f64
};
RiskPropagationReport {
entities: risk_scores,
mean_risk,
max_risk,
}
}
#[derive(Debug, Clone)]
pub struct NodeEmbedding {
pub entity: String,
pub embedding: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct BehaviorEmbeddingConfig {
pub use_topology_features: bool,
pub use_access_patterns: bool,
}
impl Default for BehaviorEmbeddingConfig {
fn default() -> Self {
Self {
use_topology_features: true,
use_access_patterns: true,
}
}
}
#[derive(Debug, Clone)]
pub struct GeometricAnomalyResult {
pub entity: String,
pub anomaly_score: f64,
pub nearest_neighbors: Vec<String>,
pub knn_distance: f64,
}
#[derive(Debug, Clone)]
pub struct GeometricAnomalyReport {
pub anomalies: Vec<GeometricAnomalyResult>,
pub mean_distance: f64,
pub threshold: f64,
pub total_entities: usize,
}
#[derive(Debug, Clone)]
pub struct SpectralCluster {
pub cluster_id: usize,
pub members: Vec<String>,
pub center: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct ClusteringResult {
pub clusters: Vec<SpectralCluster>,
pub assignments: HashMap<String, usize>,
pub modularity: f64,
}
#[allow(clippy::cast_precision_loss)]
pub fn compute_behavior_embeddings(
vault: &Vault,
config: BehaviorEmbeddingConfig,
) -> Vec<NodeEmbedding> {
let entities = collect_entity_keys(vault);
if entities.is_empty() {
return Vec::new();
}
let all_secrets: Vec<String> = collect_vault_secrets(vault)
.into_iter()
.map(|(node_key, _name)| node_key)
.collect();
let (pagerank_scores, eig_scores) = if config.use_topology_features {
let pr = vault
.graph
.pagerank(None)
.map_or_else(|_| HashMap::new(), |r| r.scores);
let eig = vault
.graph
.eigenvector_centrality(None)
.map_or_else(|_| HashMap::new(), |r| r.scores);
(pr, eig)
} else {
(HashMap::new(), HashMap::new())
};
let mut embeddings = Vec::with_capacity(entities.len());
for entity in &entities {
if entity == Vault::ROOT {
continue;
}
let mut features = Vec::new();
if config.use_topology_features {
let node_id = vault.find_entity_node(entity);
let pr = node_id
.and_then(|id| pagerank_scores.get(&id).copied())
.unwrap_or(0.0);
features.push(pr as f32);
let eig = node_id
.and_then(|id| eig_scores.get(&id).copied())
.unwrap_or(0.0);
features.push(eig as f32);
let clustering = compute_local_clustering(vault, entity);
features.push(clustering);
}
if config.use_access_patterns {
let reachable = bfs_reachable_secrets(vault, entity);
let secret_count = all_secrets.len().max(1);
for secret_key in &all_secrets {
if reachable.contains_key(secret_key) {
features.push(1.0);
} else {
features.push(0.0);
}
}
let access_sum: f32 = features
.iter()
.skip(if config.use_topology_features { 3 } else { 0 })
.sum();
if access_sum > 0.0 {
let norm = (secret_count as f32).sqrt();
let start = if config.use_topology_features { 3 } else { 0 };
for f in &mut features[start..] {
*f /= norm;
}
}
}
let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for f in &mut features {
*f /= norm;
}
}
embeddings.push(NodeEmbedding {
entity: entity.clone(),
embedding: features,
});
}
embeddings
}
#[allow(clippy::cast_precision_loss)]
fn compute_local_clustering(vault: &Vault, entity: &str) -> f32 {
let neighbors: Vec<String> = get_outgoing_edges_full(vault, entity)
.into_iter()
.filter(|e| is_allowed_edge_type(&e.edge_type))
.map(|e| e.target_key)
.collect();
let n = neighbors.len();
if n < 2 {
return 0.0;
}
let neighbor_set: HashSet<&str> = neighbors.iter().map(String::as_str).collect();
let mut triangle_edges = 0usize;
for neighbor in &neighbors {
let edges = get_outgoing_edges_full(vault, neighbor);
for e in edges {
if is_allowed_edge_type(&e.edge_type) && neighbor_set.contains(e.target_key.as_str()) {
triangle_edges += 1;
}
}
}
let possible = n * (n - 1);
if possible == 0 {
0.0
} else {
triangle_edges as f32 / possible as f32
}
}
#[allow(clippy::cast_precision_loss)]
pub fn detect_geometric_anomalies(
embeddings: &[NodeEmbedding],
k: usize,
threshold_multiplier: f64,
) -> GeometricAnomalyReport {
let n = embeddings.len();
if n == 0 {
return GeometricAnomalyReport {
anomalies: Vec::new(),
mean_distance: 0.0,
threshold: 0.0,
total_entities: 0,
};
}
let effective_k = k.min(n.saturating_sub(1)).max(1);
let mut knn_distances: Vec<f64> = Vec::with_capacity(n);
let mut knn_neighbors: Vec<Vec<(usize, f64)>> = Vec::with_capacity(n);
for i in 0..n {
let mut distances: Vec<(usize, f64)> = (0..n)
.filter(|&j| j != i)
.map(|j| {
let d = euclidean_distance(&embeddings[i].embedding, &embeddings[j].embedding);
(j, d)
})
.collect();
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
distances.truncate(effective_k);
let knn_dist = distances.last().map_or(0.0, |&(_, d)| d);
knn_distances.push(knn_dist);
knn_neighbors.push(distances);
}
let sum: f64 = knn_distances.iter().sum();
let mean = if n > 0 { sum / n as f64 } else { 0.0 };
let variance: f64 = knn_distances
.iter()
.map(|d| (d - mean).powi(2))
.sum::<f64>()
/ n.max(1) as f64;
let stddev = variance.sqrt();
let threshold = threshold_multiplier.mul_add(stddev, mean);
let mut anomalies = Vec::new();
for (i, dist) in knn_distances.iter().enumerate() {
if *dist > threshold {
let neighbors = knn_neighbors[i]
.iter()
.map(|&(j, _)| embeddings[j].entity.clone())
.collect();
anomalies.push(GeometricAnomalyResult {
entity: embeddings[i].entity.clone(),
anomaly_score: (*dist - mean) / stddev.max(f64::EPSILON),
nearest_neighbors: neighbors,
knn_distance: *dist,
});
}
}
anomalies.sort_by(|a, b| {
b.anomaly_score
.partial_cmp(&a.anomaly_score)
.unwrap_or(Ordering::Equal)
});
GeometricAnomalyReport {
anomalies,
mean_distance: mean,
threshold,
total_entities: n,
}
}
fn euclidean_distance(a: &[f32], b: &[f32]) -> f64 {
let min_len = a.len().min(b.len());
let mut sum = 0.0_f64;
for i in 0..min_len {
let diff = f64::from(a[i]) - f64::from(b[i]);
sum += diff * diff;
}
for v in a.iter().skip(min_len) {
sum += f64::from(*v) * f64::from(*v);
}
for v in b.iter().skip(min_len) {
sum += f64::from(*v) * f64::from(*v);
}
sum.sqrt()
}
#[allow(clippy::cast_precision_loss)]
pub fn cluster_entities(vault: &Vault) -> ClusteringResult {
let louvain = vault.graph.louvain_communities(None);
let entities = collect_entity_keys(vault);
let entity_set: HashSet<&str> = entities.iter().map(String::as_str).collect();
match louvain {
Ok(result) => {
let mut cluster_map: HashMap<usize, Vec<String>> = HashMap::new();
let mut assignments = HashMap::new();
for (node_id, community_id) in &result.communities {
#[allow(clippy::cast_possible_truncation)]
let cid = *community_id as usize;
if let Some(key) = vault.node_entity_key(*node_id) {
if entity_set.contains(key.as_str()) {
cluster_map.entry(cid).or_default().push(key.clone());
assignments.insert(key, cid);
}
}
}
let clusters: Vec<SpectralCluster> = cluster_map
.into_iter()
.map(|(id, members)| SpectralCluster {
cluster_id: id,
members,
center: Vec::new(), })
.collect();
ClusteringResult {
clusters,
assignments,
modularity: result.modularity.unwrap_or(0.0),
}
},
Err(_) => ClusteringResult {
clusters: Vec::new(),
assignments: HashMap::new(),
modularity: 0.0,
},
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use graph_engine::GraphEngine;
use tensor_store::TensorStore;
use super::*;
use crate::VaultConfig;
fn create_vault() -> Vault {
let store = TensorStore::new();
let graph = Arc::new(GraphEngine::new());
Vault::new(b"test_password", graph, store, VaultConfig::default()).unwrap()
}
#[test]
fn test_explain_access_direct_path() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Admin)
.unwrap();
let result = explain_access(&vault, "user:alice", "db/password");
assert!(result.granted);
assert_eq!(result.effective_permission, Some(Permission::Admin));
assert!(!result.paths.is_empty());
assert!(result.denial_reason.is_none());
}
#[test]
fn test_explain_access_transitive_path() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "group:devs", "db/password", Permission::Admin)
.unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let devs_node = vault.get_or_create_entity_node("group:devs");
vault
.graph
.create_edge(
alice_node,
devs_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let result = explain_access(&vault, "user:alice", "db/password");
assert!(result.granted);
assert!(result.effective_permission.is_some());
assert!(result.paths.len() >= 1);
assert!(result.paths[0].len() >= 2);
}
#[test]
fn test_explain_access_no_path() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
let result = explain_access(&vault, "user:bob", "db/password");
assert!(!result.granted);
assert_eq!(result.effective_permission, None);
assert!(result.paths.is_empty());
assert_eq!(result.denial_reason, Some(DenialReason::NoPath));
}
#[test]
fn test_explain_access_insufficient_permission() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Read)
.unwrap();
let result = explain_access(&vault, "user:alice", "db/password");
assert!(result.granted);
assert_eq!(result.effective_permission, Some(Permission::Read));
}
#[test]
fn test_explain_access_multiple_paths() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Read)
.unwrap();
vault
.grant_with_permission(
Vault::ROOT,
"group:admins",
"db/password",
Permission::Admin,
)
.unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let admins_node = vault.get_or_create_entity_node("group:admins");
vault
.graph
.create_edge(
alice_node,
admins_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let result = explain_access(&vault, "user:alice", "db/password");
assert!(result.granted);
assert!(result.paths.len() >= 2);
assert!(result.effective_permission.is_some());
}
#[test]
fn test_blast_radius_single_secret() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Read)
.unwrap();
let result = blast_radius(&vault, "user:alice");
assert_eq!(result.entity, "user:alice");
assert_eq!(result.total_secrets, 1);
assert_eq!(result.secrets[0].secret_name, "db/password");
assert_eq!(result.secrets[0].permission, Permission::Read);
}
#[test]
fn test_blast_radius_multiple_secrets() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault.set(Vault::ROOT, "api/key", "secret2").unwrap();
vault.set(Vault::ROOT, "ssh/key", "secret3").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "api/key", Permission::Write)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "ssh/key", Permission::Read)
.unwrap();
let result = blast_radius(&vault, "user:alice");
assert_eq!(result.total_secrets, 3);
assert_eq!(result.secrets[0].permission, Permission::Admin);
}
#[test]
fn test_blast_radius_no_access() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
let result = blast_radius(&vault, "user:nobody");
assert_eq!(result.total_secrets, 0);
assert!(result.secrets.is_empty());
}
#[test]
fn test_blast_radius_transitive() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "group:devs", "db/password", Permission::Write)
.unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let devs_node = vault.get_or_create_entity_node("group:devs");
vault
.graph
.create_edge(
alice_node,
devs_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let result = blast_radius(&vault, "user:alice");
assert_eq!(result.total_secrets, 1);
assert_eq!(result.secrets[0].secret_name, "db/password");
}
#[test]
fn test_simulate_grant_new_access() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
let result = simulate_grant(&vault, "user:alice", "db/password", Permission::Write);
assert_eq!(result.target_entity, "user:alice");
assert!(result.new_accesses.iter().any(|a| a.entity == "user:alice"));
assert!(result.total_affected >= 1);
}
#[test]
fn test_simulate_grant_upgrade() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Read)
.unwrap();
let result = simulate_grant(&vault, "user:alice", "db/password", Permission::Write);
assert!(result
.new_accesses
.iter()
.any(|a| a.entity == "user:alice" && a.permission == Permission::Write));
}
#[test]
fn test_simulate_grant_no_change() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Admin)
.unwrap();
let result = simulate_grant(&vault, "user:alice", "db/password", Permission::Read);
let alice_entry = result
.new_accesses
.iter()
.find(|a| a.entity == "user:alice");
assert!(alice_entry.is_none());
}
#[test]
fn test_simulate_grant_transitive_impact() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let group_node = vault.get_or_create_entity_node("group:devs");
vault
.graph
.create_edge(
alice_node,
group_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let result = simulate_grant(&vault, "group:devs", "db/password", Permission::Write);
assert!(result.new_accesses.iter().any(|a| a.entity == "user:alice"));
}
#[test]
fn test_security_audit_no_cycles() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Read)
.unwrap();
let report = security_audit(&vault);
assert!(report.cycles.is_empty());
assert!(report.total_entities > 0);
}
#[test]
fn test_security_audit_detects_cycle() {
let vault = create_vault();
let a = vault.get_or_create_entity_node("group:a");
let b = vault.get_or_create_entity_node("group:b");
let c = vault.get_or_create_entity_node("group:c");
vault
.graph
.create_edge(a, b, "MEMBER", std::collections::HashMap::new(), true)
.unwrap();
vault
.graph
.create_edge(b, c, "MEMBER", std::collections::HashMap::new(), true)
.unwrap();
vault
.graph
.create_edge(c, a, "MEMBER", std::collections::HashMap::new(), true)
.unwrap();
let report = security_audit(&vault);
assert!(
!report.cycles.is_empty(),
"Should detect the A->B->C->A cycle"
);
let cycle = &report.cycles[0];
assert!(cycle.entities.len() >= 3);
}
#[test]
fn test_security_audit_spof_detection() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault
.grant_with_permission(
Vault::ROOT,
"group:bottleneck",
"db/password",
Permission::Admin,
)
.unwrap();
vault.set(Vault::ROOT, "api/key", "secret2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "group:gateway", "api/key", Permission::Admin)
.unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let gw_node = vault.get_or_create_entity_node("group:gateway");
vault
.graph
.create_edge(
alice_node,
gw_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let report = security_audit(&vault);
assert!(report.total_entities > 0);
assert!(report.total_secrets > 0);
}
#[test]
fn test_security_audit_over_privileged() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass1", "s1").unwrap();
vault.set(Vault::ROOT, "db/pass2", "s2").unwrap();
vault.set(Vault::ROOT, "db/pass3", "s3").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass2", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass3", Permission::Admin)
.unwrap();
let report = security_audit(&vault);
assert!(!report.over_privileged.is_empty());
let alice_entry = report
.over_privileged
.iter()
.find(|e| e.entity == "user:alice");
assert!(alice_entry.is_some());
assert_eq!(alice_entry.unwrap().reachable_secrets, 3);
assert_eq!(alice_entry.unwrap().admin_count, 3);
}
#[test]
fn test_find_critical_entities_ranking() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "s1").unwrap();
vault.set(Vault::ROOT, "api/key", "s2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "api/key", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "db/password", Permission::Read)
.unwrap();
let critical = find_critical_entities(&vault);
assert!(!critical.is_empty());
let alice_pos = critical.iter().position(|c| c.entity == "user:alice");
let bob_pos = critical.iter().position(|c| c.entity == "user:bob");
if let (Some(a), Some(b)) = (alice_pos, bob_pos) {
assert!(a < b, "alice should rank higher than bob");
}
}
#[test]
fn test_explain_access_through_vault_ops() {
let vault = create_vault();
vault.set(Vault::ROOT, "prod/db", "password123").unwrap();
vault
.grant_with_permission(Vault::ROOT, "team:backend", "prod/db", Permission::Write)
.unwrap();
let alice_node = vault.get_or_create_entity_node("user:alice");
let team_node = vault.get_or_create_entity_node("team:backend");
vault
.graph
.create_edge(
alice_node,
team_node,
"MEMBER",
std::collections::HashMap::new(),
true,
)
.unwrap();
let explanation = vault.explain_access("user:alice", "prod/db");
assert!(explanation.granted);
assert!(explanation.effective_permission.is_some());
assert_eq!(explanation.entity, "user:alice");
assert_eq!(explanation.secret, "prod/db");
}
#[test]
fn test_blast_radius_through_vault_ops() {
let vault = create_vault();
vault.set(Vault::ROOT, "prod/db", "pass1").unwrap();
vault.set(Vault::ROOT, "prod/api", "key1").unwrap();
vault.set(Vault::ROOT, "staging/db", "pass2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "prod/db", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "prod/api", Permission::Write)
.unwrap();
let radius = vault.blast_radius("user:alice");
assert_eq!(radius.total_secrets, 2);
}
#[test]
fn test_simulate_grant_through_vault_ops() {
let vault = create_vault();
vault.set(Vault::ROOT, "prod/db", "pass1").unwrap();
let sim = vault.simulate_grant("user:alice", "prod/db", Permission::Write);
assert_eq!(sim.target_entity, "user:alice");
assert_eq!(sim.secret, "prod/db");
assert!(sim.total_affected >= 1);
}
#[test]
fn test_security_audit_full_vault() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault.set(Vault::ROOT, "api/key", "s2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "api/key", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "api/key", Permission::Write)
.unwrap();
let report = vault.security_audit();
assert!(report.total_entities >= 2);
assert_eq!(report.total_secrets, 2);
assert!(report.total_edges > 0);
}
#[test]
fn test_scoped_explain_and_blast_radius() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/password", "secret1").unwrap();
vault.set(Vault::ROOT, "api/key", "secret2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/password", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "api/key", Permission::Read)
.unwrap();
let scoped = vault.scope("user:alice");
let explanation = scoped.explain_access("db/password");
assert!(explanation.granted);
assert_eq!(explanation.entity, "user:alice");
let radius = scoped.blast_radius();
assert_eq!(radius.total_secrets, 2);
assert_eq!(radius.entity, "user:alice");
}
#[test]
fn test_privilege_analysis_empty_vault() {
let vault = create_vault();
let report = privilege_analysis(&vault);
assert!(report.entities.is_empty());
assert_eq!(report.mean_privilege_score, 0.0);
assert_eq!(report.max_privilege_score, 0.0);
}
#[test]
fn test_privilege_analysis_single_entity() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass", Permission::Admin)
.unwrap();
let report = privilege_analysis(&vault);
assert_eq!(report.entities.len(), 1);
assert_eq!(report.entities[0].entity, "user:alice");
assert_eq!(report.entities[0].reachable_secrets, 1);
assert_eq!(report.entities[0].admin_count, 1);
}
#[test]
fn test_privilege_analysis_multiple_entities() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass1", "s1").unwrap();
vault.set(Vault::ROOT, "db/pass2", "s2").unwrap();
vault.set(Vault::ROOT, "db/pass3", "s3").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass2", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass3", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "db/pass1", Permission::Read)
.unwrap();
let report = privilege_analysis(&vault);
assert!(report.entities.len() >= 2);
assert_eq!(report.entities[0].entity, "user:alice");
}
#[test]
fn test_privilege_analysis_skips_root() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass", Permission::Read)
.unwrap();
let report = privilege_analysis(&vault);
assert!(report.entities.iter().all(|e| e.entity != Vault::ROOT));
}
#[test]
fn test_privilege_analysis_admin_write_read_counts() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault.set(Vault::ROOT, "s3", "v3").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s2", Permission::Write)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s3", Permission::Read)
.unwrap();
let report = privilege_analysis(&vault);
let alice = report
.entities
.iter()
.find(|e| e.entity == "user:alice")
.unwrap();
assert_eq!(alice.admin_count, 1);
assert_eq!(alice.write_count, 1);
assert_eq!(alice.read_count, 1);
assert_eq!(alice.reachable_secrets, 3);
}
#[test]
fn test_privilege_analysis_scores_computation() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s2", Permission::Read)
.unwrap();
let report = privilege_analysis(&vault);
let alice = report
.entities
.iter()
.find(|e| e.entity == "user:alice")
.unwrap();
let expected = alice.pagerank_score * alice.reachable_secrets as f64;
assert!(
(alice.privilege_score - expected).abs() < f64::EPSILON,
"privilege_score should equal pagerank * reachable_secrets"
);
assert!(report.max_privilege_score >= report.mean_privilege_score);
}
#[test]
fn test_delegation_anomaly_empty_vault() {
let vault = create_vault();
let scores = delegation_anomaly_scores(&vault);
assert!(scores.is_empty());
}
#[test]
fn test_delegation_anomaly_normal_grants() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "db/pass", Permission::Read)
.unwrap();
let scores = delegation_anomaly_scores(&vault);
for s in &scores {
assert!(s.anomaly_score > 0.5);
}
}
#[test]
fn test_delegation_anomaly_isolated_grant() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:lone", "db/pass", Permission::Admin)
.unwrap();
let scores = delegation_anomaly_scores(&vault);
for s in &scores {
assert!(
s.anomaly_score > 0.5,
"isolated grant should have high anomaly"
);
}
}
#[test]
fn test_delegation_anomaly_sorted_by_score() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s2", Permission::Read)
.unwrap();
let scores = delegation_anomaly_scores(&vault);
for w in scores.windows(2) {
assert!(
w[0].anomaly_score >= w[1].anomaly_score,
"results should be sorted by anomaly_score descending"
);
}
}
#[test]
fn test_delegation_anomaly_skips_root() {
let vault = create_vault();
vault.set(Vault::ROOT, "db/pass", "s1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "db/pass", Permission::Read)
.unwrap();
let scores = delegation_anomaly_scores(&vault);
assert!(
scores.iter().all(|s| s.entity != Vault::ROOT),
"ROOT should not appear in anomaly scores"
);
}
#[test]
fn test_infer_roles_empty_vault() {
let vault = create_vault();
let result = infer_roles(&vault);
assert!(result.roles.is_empty());
assert_eq!(result.modularity, 0.0);
}
#[test]
fn test_infer_roles_two_groups() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:charlie", "s2", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:dave", "s2", Permission::Admin)
.unwrap();
let result = infer_roles(&vault);
let total_assigned: usize = result.roles.iter().map(|r| r.members.len()).sum();
let total = total_assigned + result.unassigned.len();
assert!(total >= 4, "all entities should appear somewhere");
}
#[test]
fn test_infer_roles_common_secrets() {
let vault = create_vault();
vault.set(Vault::ROOT, "shared", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "shared", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "shared", Permission::Read)
.unwrap();
let result = infer_roles(&vault);
for role in &result.roles {
let has_alice = role.members.iter().any(|m| m == "user:alice");
let has_bob = role.members.iter().any(|m| m == "user:bob");
if has_alice && has_bob {
assert!(
!role.common_secrets.is_empty(),
"common secrets should include the shared secret"
);
}
}
}
#[test]
fn test_infer_roles_singletons_unassigned() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:lonely", "s1", Permission::Read)
.unwrap();
let result = infer_roles(&vault);
let in_role = result
.roles
.iter()
.any(|r| r.members.iter().any(|m| m == "user:lonely"));
let in_unassigned = result.unassigned.iter().any(|u| u == "user:lonely");
assert!(
in_role || in_unassigned,
"lonely entity should appear in roles or unassigned"
);
}
#[test]
fn test_infer_roles_modularity() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s1", Permission::Read)
.unwrap();
let result = infer_roles(&vault);
assert!(result.modularity.is_finite());
}
#[test]
fn test_trust_transitivity_empty() {
let vault = create_vault();
let report = trust_transitivity(&vault);
assert!(report.entities.is_empty());
assert_eq!(report.total_triangles, 0);
}
#[test]
fn test_trust_transitivity_triangle() {
let vault = create_vault();
let a = vault.get_or_create_entity_node("user:a");
let b = vault.get_or_create_entity_node("user:b");
let c = vault.get_or_create_entity_node("user:c");
let props = std::collections::HashMap::new();
vault
.graph
.create_edge(a, b, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(b, c, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(c, a, "MEMBER", props, true)
.unwrap();
let report = trust_transitivity(&vault);
assert!(
report.total_triangles >= 1,
"should detect at least one triangle"
);
let non_zero = report
.entities
.iter()
.filter(|e| e.trust_score > 0.0)
.count();
assert!(non_zero > 0, "triangle participants should have trust > 0");
}
#[test]
fn test_trust_transitivity_global_clustering() {
let vault = create_vault();
let a = vault.get_or_create_entity_node("user:a");
let b = vault.get_or_create_entity_node("user:b");
let c = vault.get_or_create_entity_node("user:c");
let props = std::collections::HashMap::new();
vault
.graph
.create_edge(a, b, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(b, c, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(c, a, "MEMBER", props, true)
.unwrap();
let report = trust_transitivity(&vault);
assert!(
report.global_clustering.is_finite(),
"global clustering should be finite"
);
}
#[test]
fn test_trust_transitivity_scoring_formula() {
let vault = create_vault();
let a = vault.get_or_create_entity_node("user:a");
let b = vault.get_or_create_entity_node("user:b");
let c = vault.get_or_create_entity_node("user:c");
let props = std::collections::HashMap::new();
vault
.graph
.create_edge(a, b, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(b, c, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(c, a, "MEMBER", props, true)
.unwrap();
let report = trust_transitivity(&vault);
for ent in &report.entities {
let expected = ent.clustering_coefficient * (1.0 + (ent.triangle_count as f64).ln_1p());
assert!(
(ent.trust_score - expected).abs() < 1e-10,
"trust_score should match formula for {}",
ent.entity
);
}
}
#[test]
fn test_trust_transitivity_sorted() {
let vault = create_vault();
let a = vault.get_or_create_entity_node("user:a");
let b = vault.get_or_create_entity_node("user:b");
let c = vault.get_or_create_entity_node("user:c");
let d = vault.get_or_create_entity_node("user:d");
let props = std::collections::HashMap::new();
vault
.graph
.create_edge(a, b, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(b, c, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(c, a, "MEMBER", props.clone(), true)
.unwrap();
vault
.graph
.create_edge(d, a, "MEMBER", props, true)
.unwrap();
let report = trust_transitivity(&vault);
for w in report.entities.windows(2) {
assert!(
w[0].trust_score >= w[1].trust_score,
"results should be sorted by trust_score descending"
);
}
}
#[test]
fn test_risk_propagation_empty() {
let vault = create_vault();
let report = risk_propagation(&vault);
assert!(report.entities.is_empty());
assert_eq!(report.mean_risk, 0.0);
assert_eq!(report.max_risk, 0.0);
}
#[test]
fn test_risk_propagation_admin_amplifies() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:admin_user", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:admin_user", "s2", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:reader", "s1", Permission::Read)
.unwrap();
let report = risk_propagation(&vault);
let admin_entry = report
.entities
.iter()
.find(|e| e.entity == "user:admin_user");
let reader_entry = report.entities.iter().find(|e| e.entity == "user:reader");
assert!(admin_entry.is_some());
assert!(reader_entry.is_some());
assert!(
admin_entry.unwrap().reachable_admin_secrets
>= reader_entry.unwrap().reachable_admin_secrets
);
}
#[test]
fn test_risk_propagation_read_only_low_risk() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:reader", "s1", Permission::Read)
.unwrap();
let report = risk_propagation(&vault);
let reader = report
.entities
.iter()
.find(|e| e.entity == "user:reader")
.unwrap();
assert_eq!(
reader.reachable_admin_secrets, 0,
"read-only entity should have zero admin secrets"
);
assert!(
reader.risk_contributors.is_empty(),
"read-only entity should have no risk contributors"
);
}
#[test]
fn test_risk_propagation_contributors() {
let vault = create_vault();
vault.set(Vault::ROOT, "critical/db", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "critical/db", Permission::Admin)
.unwrap();
let report = risk_propagation(&vault);
let alice = report
.entities
.iter()
.find(|e| e.entity == "user:alice")
.unwrap();
assert_eq!(alice.reachable_admin_secrets, 1);
assert_eq!(alice.risk_contributors.len(), 1);
assert_eq!(alice.risk_contributors[0].secret, "critical/db");
assert_eq!(alice.risk_contributors[0].permission, Permission::Admin);
}
#[test]
fn test_risk_propagation_sorted() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s2", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s1", Permission::Read)
.unwrap();
let report = risk_propagation(&vault);
for w in report.entities.windows(2) {
assert!(
w[0].risk_score >= w[1].risk_score,
"results should be sorted by risk_score descending"
);
}
}
#[test]
fn test_scoped_privilege_analysis() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
let scoped = vault.scope("user:alice");
let report = scoped.privilege_analysis();
assert!(!report.entities.is_empty());
}
#[test]
fn test_scoped_delegation_anomaly() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
let scoped = vault.scope("user:alice");
let scores = scoped.delegation_anomaly_scores();
for s in &scores {
assert!(s.anomaly_score > 0.5);
}
}
#[test]
fn test_scoped_infer_roles() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s1", Permission::Read)
.unwrap();
let scoped = vault.scope("user:alice");
let result = scoped.infer_roles();
assert!(result.modularity.is_finite());
}
#[test]
fn test_scoped_trust_and_risk() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
let scoped = vault.scope("user:alice");
let trust = scoped.trust_transitivity();
assert!(trust.global_clustering.is_finite());
let risk = scoped.risk_propagation();
assert!(risk.max_risk >= risk.mean_risk);
}
#[test]
fn test_behavior_embeddings_topology_features() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s2", Permission::Read)
.unwrap();
let config = BehaviorEmbeddingConfig {
use_topology_features: true,
use_access_patterns: false,
};
let embeddings = compute_behavior_embeddings(&vault, config);
assert!(!embeddings.is_empty());
for emb in &embeddings {
assert_eq!(emb.embedding.len(), 3);
let norm: f32 = emb.embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(norm < f32::EPSILON || (norm - 1.0).abs() < 0.01);
}
}
#[test]
fn test_behavior_embeddings_access_patterns() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Admin)
.unwrap();
let config = BehaviorEmbeddingConfig {
use_topology_features: false,
use_access_patterns: true,
};
let embeddings = compute_behavior_embeddings(&vault, config);
let alice = embeddings.iter().find(|e| e.entity == "user:alice");
assert!(alice.is_some());
assert!(!alice.unwrap().embedding.is_empty());
}
#[test]
fn test_behavior_embeddings_empty_graph() {
let vault = create_vault();
let config = BehaviorEmbeddingConfig::default();
let embeddings = compute_behavior_embeddings(&vault, config);
assert!(embeddings.is_empty());
}
#[test]
fn test_geometric_anomaly_isolated_entity() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault.set(Vault::ROOT, "s3", "v3").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:alice", "s2", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:bob", "s2", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:charlie", "s3", Permission::Admin)
.unwrap();
let config = BehaviorEmbeddingConfig::default();
let embeddings = compute_behavior_embeddings(&vault, config);
let report = detect_geometric_anomalies(&embeddings, 1, 1.0);
assert_eq!(report.total_entities, embeddings.len());
assert!(report.mean_distance >= 0.0);
}
#[test]
fn test_geometric_anomaly_normal_cluster() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:a", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:b", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:c", "s1", Permission::Read)
.unwrap();
let config = BehaviorEmbeddingConfig::default();
let embeddings = compute_behavior_embeddings(&vault, config);
let report = detect_geometric_anomalies(&embeddings, 1, 10.0);
assert!(report.anomalies.is_empty());
}
#[test]
fn test_geometric_anomaly_threshold() {
let embeddings = vec![
NodeEmbedding {
entity: "a".to_string(),
embedding: vec![0.0, 0.0],
},
NodeEmbedding {
entity: "b".to_string(),
embedding: vec![0.1, 0.0],
},
NodeEmbedding {
entity: "c".to_string(),
embedding: vec![0.0, 0.1],
},
NodeEmbedding {
entity: "outlier".to_string(),
embedding: vec![10.0, 10.0],
},
];
let report = detect_geometric_anomalies(&embeddings, 1, 1.5);
assert_eq!(report.total_entities, 4);
assert!(report.threshold > report.mean_distance);
assert!(
report.anomalies.iter().any(|a| a.entity == "outlier"),
"outlier entity should be flagged"
);
}
#[test]
fn test_geometric_anomaly_k_exceeds_entities() {
let embeddings = vec![
NodeEmbedding {
entity: "a".to_string(),
embedding: vec![0.0],
},
NodeEmbedding {
entity: "b".to_string(),
embedding: vec![1.0],
},
];
let report = detect_geometric_anomalies(&embeddings, 100, 2.0);
assert_eq!(report.total_entities, 2);
}
#[test]
fn test_cluster_entities_two_communities() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault.set(Vault::ROOT, "s2", "v2").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:a1", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:a2", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:b1", "s2", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:b2", "s2", Permission::Read)
.unwrap();
let result = cluster_entities(&vault);
assert!(result.modularity.is_finite());
}
#[test]
fn test_cluster_entities_single_community() {
let vault = create_vault();
vault.set(Vault::ROOT, "s1", "v1").unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:a", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:b", "s1", Permission::Read)
.unwrap();
vault
.grant_with_permission(Vault::ROOT, "user:c", "s1", Permission::Read)
.unwrap();
let result = cluster_entities(&vault);
assert!(result.modularity.is_finite());
}
#[test]
fn test_cluster_entities_empty_vault() {
let vault = create_vault();
let result = cluster_entities(&vault);
assert!(result.clusters.is_empty());
assert!(result.assignments.is_empty());
}
}