use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::VtcRole;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VtcAclEntry {
pub did: String,
pub role: VtcRole,
pub label: Option<String>,
#[serde(default)]
pub allowed_contexts: Vec<String>,
pub created_at: u64,
pub created_by: String,
#[serde(default)]
pub expires_at: Option<u64>,
}
impl VtcAclEntry {
pub fn is_expired(&self, now_unix: u64) -> bool {
match self.expires_at {
Some(deadline) => now_unix >= deadline,
None => false,
}
}
}
pub(crate) fn decode(bytes: &[u8]) -> Result<VtcAclEntry, AppError> {
serde_json::from_slice(bytes)
.map_err(|e| AppError::Internal(format!("VtcAclEntry decode: {e}")))
}
pub(crate) async fn iter(ks: &KeyspaceHandle) -> Result<Vec<VtcAclEntry>, AppError> {
let raw = ks.prefix_iter_raw(b"acl:".to_vec()).await?;
let mut out = Vec::with_capacity(raw.len());
for (_k, v) in raw {
match decode(&v) {
Ok(entry) => out.push(entry),
Err(err) => {
tracing::warn!(error = %err, "skipping unparseable acl entry");
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn is_expired_returns_false_for_permanent_entries() {
let entry = sample_entry(None);
assert!(!entry.is_expired(u64::MAX));
}
#[test]
fn is_expired_returns_true_after_deadline() {
let entry = sample_entry(Some(100));
assert!(!entry.is_expired(50));
assert!(entry.is_expired(101));
}
#[test]
fn round_trip_through_json() {
let entry = sample_entry(Some(200));
let bytes = serde_json::to_vec(&entry).unwrap();
let parsed = decode(&bytes).unwrap();
assert_eq!(parsed, entry);
}
#[test]
fn decodes_legacy_role_admin_wire_shape() {
let legacy = json!({
"did": "did:key:zAdmin",
"role": "admin",
"label": null,
"allowed_contexts": [],
"created_at": 0,
"created_by": "did:key:vtc-install"
});
let bytes = serde_json::to_vec(&legacy).unwrap();
let entry = decode(&bytes).unwrap();
assert_eq!(entry.role, VtcRole::Admin);
assert_eq!(entry.did, "did:key:zAdmin");
assert_eq!(entry.expires_at, None);
}
#[test]
fn custom_role_round_trips() {
let entry = VtcAclEntry {
did: "did:key:zEditor".into(),
role: VtcRole::custom("editor").unwrap(),
label: Some("badge holder".into()),
allowed_contexts: vec![],
created_at: 1,
created_by: "did:key:zAdmin".into(),
expires_at: None,
};
let bytes = serde_json::to_vec(&entry).unwrap();
let parsed = decode(&bytes).unwrap();
assert_eq!(parsed.role, VtcRole::Custom("editor".into()));
assert_eq!(parsed, entry);
}
fn sample_entry(expires_at: Option<u64>) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zSomeMember".into(),
role: VtcRole::Member,
label: None,
allowed_contexts: vec![],
created_at: 42,
created_by: "did:key:vtc-install".into(),
expires_at,
}
}
}