use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::audit::AuditLog;
use crate::vault::Vault;
use crate::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessTensorConfig {
pub bucket_size_ms: i64,
pub num_buckets: usize,
pub start_time_ms: Option<i64>,
pub operations: Option<Vec<String>>,
}
impl Default for AccessTensorConfig {
fn default() -> Self {
Self {
bucket_size_ms: 3_600_000,
num_buckets: 168,
start_time_ms: None,
operations: None,
}
}
}
pub struct AccessTensor {
pub(crate) entity_index: HashMap<String, usize>,
pub(crate) secret_index: HashMap<String, usize>,
pub(crate) data: Vec<f32>,
pub(crate) dimensions: (usize, usize, usize),
pub(crate) config: AccessTensorConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityAccessProfile {
pub entity: String,
pub mean_rate: f64,
pub rate_stddev: f64,
pub peak_bucket: usize,
pub entropy: f64,
pub total_accesses: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretAccessProfile {
pub secret: String,
pub unique_accessors: usize,
pub peak_bucket: usize,
pub burstiness: f64,
}
impl AccessTensor {
pub fn from_vault(vault: &Vault, config: AccessTensorConfig) -> Result<Self> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let start = config
.start_time_ms
.unwrap_or_else(|| now_ms - config.bucket_size_ms * config.num_buckets as i64);
let audit = AuditLog::new(&vault.store, Some(*vault.audit_key()));
let entries = audit.since(start);
let mut entity_set: Vec<String> = Vec::new();
let mut secret_set: Vec<String> = Vec::new();
let mut entity_map: HashMap<String, usize> = HashMap::new();
let mut secret_map: HashMap<String, usize> = HashMap::new();
for entry in &entries {
if let Some(ref ops) = config.operations {
let op_str = format!("{:?}", entry.operation);
if !ops.iter().any(|o| op_str.contains(o)) {
continue;
}
}
if !entity_map.contains_key(&entry.entity) {
let idx = entity_set.len();
entity_set.push(entry.entity.clone());
entity_map.insert(entry.entity.clone(), idx);
}
if !secret_map.contains_key(&entry.secret_key) {
let idx = secret_set.len();
secret_set.push(entry.secret_key.clone());
secret_map.insert(entry.secret_key.clone(), idx);
}
}
let n_entities = entity_set.len();
let n_secrets = secret_set.len();
let n_buckets = config.num_buckets;
let total = n_entities * n_secrets * n_buckets;
let mut data = vec![0.0_f32; total];
for entry in &entries {
if let Some(ref ops) = config.operations {
let op_str = format!("{:?}", entry.operation);
if !ops.iter().any(|o| op_str.contains(o)) {
continue;
}
}
let Some(&eidx) = entity_map.get(&entry.entity) else {
continue;
};
let Some(&sidx) = secret_map.get(&entry.secret_key) else {
continue;
};
#[allow(clippy::cast_sign_loss)] let bucket = ((entry.timestamp - start) / config.bucket_size_ms) as usize;
if bucket < n_buckets {
let idx = eidx * n_secrets * n_buckets + sidx * n_buckets + bucket;
data[idx] += 1.0;
}
}
Ok(Self {
entity_index: entity_map,
secret_index: secret_map,
data,
dimensions: (n_entities, n_secrets, n_buckets),
config,
})
}
pub fn get(&self, entity: &str, secret: &str, bucket: usize) -> f32 {
let Some(&eidx) = self.entity_index.get(entity) else {
return 0.0;
};
let Some(&sidx) = self.secret_index.get(secret) else {
return 0.0;
};
let (_, n_secrets, n_buckets) = self.dimensions;
if bucket >= n_buckets {
return 0.0;
}
self.data[eidx * n_secrets * n_buckets + sidx * n_buckets + bucket]
}
pub fn time_series(&self, entity: &str, secret: &str) -> Vec<f32> {
let Some(&eidx) = self.entity_index.get(entity) else {
return Vec::new();
};
let Some(&sidx) = self.secret_index.get(secret) else {
return Vec::new();
};
let (_, n_secrets, n_buckets) = self.dimensions;
let start = eidx * n_secrets * n_buckets + sidx * n_buckets;
self.data[start..start + n_buckets].to_vec()
}
pub fn entity_vector(&self, entity: &str) -> Vec<f32> {
let Some(&eidx) = self.entity_index.get(entity) else {
return Vec::new();
};
let (_, n_secrets, n_buckets) = self.dimensions;
let len = n_secrets * n_buckets;
let start = eidx * len;
self.data[start..start + len].to_vec()
}
pub fn secret_vector(&self, secret: &str) -> Vec<f32> {
let Some(&sidx) = self.secret_index.get(secret) else {
return Vec::new();
};
let (n_entities, n_secrets, n_buckets) = self.dimensions;
let mut vec = Vec::with_capacity(n_entities * n_buckets);
for eidx in 0..n_entities {
let start = eidx * n_secrets * n_buckets + sidx * n_buckets;
vec.extend_from_slice(&self.data[start..start + n_buckets]);
}
vec
}
pub fn entity_profiles(&self) -> Vec<EntityAccessProfile> {
let (_, n_secrets, n_buckets) = self.dimensions;
let mut profiles = Vec::new();
for (entity, &eidx) in &self.entity_index {
let len = n_secrets * n_buckets;
let start = eidx * len;
let slice = &self.data[start..start + len];
let mut bucket_totals = vec![0.0_f64; n_buckets];
for sidx in 0..n_secrets {
for b in 0..n_buckets {
bucket_totals[b] += f64::from(slice[sidx * n_buckets + b]);
}
}
let total: f64 = bucket_totals.iter().sum();
#[allow(clippy::cast_precision_loss)] let n = n_buckets as f64;
let mean = total / n;
let variance = bucket_totals
.iter()
.map(|v| (v - mean).powi(2))
.sum::<f64>()
/ n;
let stddev = variance.sqrt();
let peak_bucket = bucket_totals
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map_or(0, |(i, _)| i);
let entropy = if total > 0.0 {
bucket_totals
.iter()
.filter(|&&v| v > 0.0)
.map(|v| {
let p = v / total;
-p * p.ln()
})
.sum()
} else {
0.0
};
#[allow(clippy::cast_sign_loss)]
let total_accesses = total as u64;
profiles.push(EntityAccessProfile {
entity: entity.clone(),
mean_rate: mean,
rate_stddev: stddev,
peak_bucket,
entropy,
total_accesses,
});
}
profiles.sort_by(|a, b| b.total_accesses.cmp(&a.total_accesses));
profiles
}
pub fn secret_profiles(&self) -> Vec<SecretAccessProfile> {
let (n_entities, n_secrets, n_buckets) = self.dimensions;
let mut profiles = Vec::new();
for (secret, &sidx) in &self.secret_index {
let mut bucket_totals = vec![0.0_f64; n_buckets];
let mut accessor_count = 0_usize;
for eidx in 0..n_entities {
let start = eidx * n_secrets * n_buckets + sidx * n_buckets;
let slice = &self.data[start..start + n_buckets];
let entity_total: f32 = slice.iter().sum();
if entity_total > 0.0 {
accessor_count += 1;
}
for (b, val) in slice.iter().enumerate() {
bucket_totals[b] += f64::from(*val);
}
}
let total: f64 = bucket_totals.iter().sum();
#[allow(clippy::cast_precision_loss)] let n = n_buckets as f64;
let mean = total / n;
let max_bucket = bucket_totals
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let burstiness = if mean > 0.0 {
(max_bucket / mean) - 1.0
} else {
0.0
};
let peak_bucket = bucket_totals
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map_or(0, |(i, _)| i);
profiles.push(SecretAccessProfile {
secret: secret.clone(),
unique_accessors: accessor_count,
peak_bucket,
burstiness,
});
}
profiles.sort_by(|a, b| b.unique_accessors.cmp(&a.unique_accessors));
profiles
}
pub fn raw_data(&self) -> &[f32] {
&self.data
}
pub fn dimensions(&self) -> (usize, usize, usize) {
self.dimensions
}
pub fn entities(&self) -> Vec<String> {
let mut result: Vec<(String, usize)> = self
.entity_index
.iter()
.map(|(k, &v)| (k.clone(), v))
.collect();
result.sort_by_key(|(_, idx)| *idx);
result.into_iter().map(|(k, _)| k).collect()
}
pub fn secrets(&self) -> Vec<String> {
let mut result: Vec<(String, usize)> = self
.secret_index
.iter()
.map(|(k, &v)| (k.clone(), v))
.collect();
result.sort_by_key(|(_, idx)| *idx);
result.into_iter().map(|(k, _)| k).collect()
}
pub fn config(&self) -> &AccessTensorConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use graph_engine::GraphEngine;
use tensor_store::TensorStore;
use super::*;
use crate::VaultConfig;
fn create_test_vault() -> Vault {
let store = TensorStore::new();
let graph = Arc::new(GraphEngine::new());
Vault::new(
b"test_password",
graph.clone(),
store,
VaultConfig::default(),
)
.unwrap()
}
fn record_audit(vault: &Vault, entity: &str, secret: &str) {
let audit = AuditLog::new(&vault.store, Some(*vault.audit_key()));
audit.record(entity, secret, &crate::audit::AuditOperation::Get);
}
#[test]
fn test_tensor_empty_vault() {
let vault = create_test_vault();
let config = AccessTensorConfig {
num_buckets: 10,
..AccessTensorConfig::default()
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
assert_eq!(tensor.dimensions(), (0, 0, 10));
assert!(tensor.raw_data().is_empty());
}
#[test]
fn test_tensor_single_entry() {
let vault = create_test_vault();
record_audit(&vault, "user:alice", "db/password");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
assert_eq!(tensor.dimensions().0, 1); assert_eq!(tensor.dimensions().1, 1);
let total: f32 = tensor.raw_data().iter().sum();
assert!((total - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_multiple_buckets() {
let vault = create_test_vault();
for _ in 0..5 {
record_audit(&vault, "user:alice", "db/password");
}
record_audit(&vault, "user:bob", "api/key");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 25,
start_time_ms: Some(now_ms - 3_600_000 * 24),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
assert_eq!(tensor.dimensions().0, 2); assert_eq!(tensor.dimensions().1, 2); }
#[test]
fn test_tensor_entity_vector() {
let vault = create_test_vault();
record_audit(&vault, "user:alice", "secret1");
record_audit(&vault, "user:alice", "secret2");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let vec = tensor.entity_vector("user:alice");
let total: f32 = vec.iter().sum();
assert!((total - 2.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_time_series() {
let vault = create_test_vault();
record_audit(&vault, "user:alice", "secret1");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let ts = tensor.time_series("user:alice", "secret1");
assert_eq!(ts.len(), 11);
let total: f32 = ts.iter().sum();
assert!((total - 1.0).abs() < f32::EPSILON);
let empty = tensor.time_series("user:nobody", "secret1");
assert!(empty.is_empty());
}
#[test]
fn test_tensor_entity_profiles() {
let vault = create_test_vault();
for _ in 0..3 {
record_audit(&vault, "user:alice", "s1");
}
record_audit(&vault, "user:bob", "s1");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let profiles = tensor.entity_profiles();
assert_eq!(profiles.len(), 2);
assert_eq!(profiles[0].entity, "user:alice");
assert_eq!(profiles[0].total_accesses, 3);
assert_eq!(profiles[1].total_accesses, 1);
}
#[test]
fn test_tensor_secret_profiles() {
let vault = create_test_vault();
record_audit(&vault, "user:alice", "popular");
record_audit(&vault, "user:bob", "popular");
record_audit(&vault, "user:alice", "private");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let profiles = tensor.secret_profiles();
assert_eq!(profiles.len(), 2);
let popular = profiles.iter().find(|p| p.secret == "popular").unwrap();
assert_eq!(popular.unique_accessors, 2);
}
#[test]
fn test_tensor_dimensions() {
let vault = create_test_vault();
record_audit(&vault, "e1", "s1");
record_audit(&vault, "e2", "s2");
record_audit(&vault, "e3", "s3");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 25,
start_time_ms: Some(now_ms - 3_600_000 * 24),
operations: None,
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let (entities, secrets, buckets) = tensor.dimensions();
assert_eq!(entities, 3);
assert_eq!(secrets, 3);
assert_eq!(buckets, 25);
assert_eq!(tensor.raw_data().len(), 3 * 3 * 25);
}
fn make_tensor_with_two_entities() -> AccessTensor {
let vault = create_test_vault();
record_audit(&vault, "user:alice", "s1");
record_audit(&vault, "user:alice", "s2");
record_audit(&vault, "user:bob", "s1");
#[allow(clippy::cast_precision_loss)]
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: None,
};
AccessTensor::from_vault(&vault, config).unwrap()
}
#[test]
fn test_tensor_get_missing_entity() {
let tensor = make_tensor_with_two_entities();
assert!((tensor.get("nonexistent", "s1", 0) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_get_missing_secret() {
let tensor = make_tensor_with_two_entities();
assert!((tensor.get("user:alice", "nonexistent", 0) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_get_out_of_bounds_bucket() {
let tensor = make_tensor_with_two_entities();
assert!((tensor.get("user:alice", "s1", 9999) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_time_series_missing_secret() {
let tensor = make_tensor_with_two_entities();
let ts = tensor.time_series("user:alice", "nonexistent");
assert!(ts.is_empty());
}
#[test]
fn test_tensor_entity_vector_missing() {
let tensor = make_tensor_with_two_entities();
let vec = tensor.entity_vector("nonexistent");
assert!(vec.is_empty());
}
#[test]
fn test_tensor_secret_vector() {
let tensor = make_tensor_with_two_entities();
let vec = tensor.secret_vector("s1");
let (n_entities, _, n_buckets) = tensor.dimensions();
assert_eq!(vec.len(), n_entities * n_buckets);
let total: f32 = vec.iter().sum();
assert!((total - 2.0).abs() < f32::EPSILON);
}
#[test]
fn test_tensor_secret_vector_missing() {
let tensor = make_tensor_with_two_entities();
let vec = tensor.secret_vector("nonexistent");
assert!(vec.is_empty());
}
#[test]
fn test_tensor_operations_filter() {
let vault = create_test_vault();
let audit = AuditLog::new(&vault.store, Some(*vault.audit_key()));
audit.record("user:alice", "s1", &crate::audit::AuditOperation::Get);
audit.record("user:alice", "s1", &crate::audit::AuditOperation::Set);
audit.record("user:bob", "s2", &crate::audit::AuditOperation::Get);
audit.record("user:bob", "s2", &crate::audit::AuditOperation::Delete);
#[allow(clippy::cast_precision_loss)]
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let config = AccessTensorConfig {
bucket_size_ms: 3_600_000,
num_buckets: 11,
start_time_ms: Some(now_ms - 3_600_000 * 10),
operations: Some(vec!["Get".to_string()]),
};
let tensor = AccessTensor::from_vault(&vault, config).unwrap();
let total: f32 = tensor.raw_data().iter().sum();
assert!((total - 2.0).abs() < f32::EPSILON);
assert_eq!(tensor.dimensions().0, 2); assert_eq!(tensor.dimensions().1, 2); }
}