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 updated_at: Option<u64>,
#[serde(default)]
pub updated_by: Option<String>,
#[serde(default)]
pub expires_at: Option<u64>,
}
impl VtcAclEntry {
pub fn act_scope(&self) -> vti_common::acl::ActScope {
vti_common::acl::act_scope_for(&crate::acl::as_vti_role(&self.role), &self.allowed_contexts)
}
pub fn is_super_admin(&self) -> bool {
self.act_scope().is_unrestricted()
}
}
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(),
updated_at: None,
updated_by: None,
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);
}
#[test]
fn act_scope_decodes_empty_scopes_by_role() {
let mut admin = sample_entry(None);
admin.role = VtcRole::Admin;
assert_eq!(admin.act_scope(), vti_common::acl::ActScope::All);
assert!(admin.is_super_admin());
assert!(admin.act_scope().covers("anything"));
for role in [VtcRole::Member, VtcRole::Custom("editor".into())] {
let mut e = sample_entry(None);
e.role = role.clone();
assert_eq!(
e.act_scope(),
vti_common::acl::ActScope::None,
"{role:?} with no scopes acts nowhere"
);
assert!(!e.is_super_admin());
assert!(!e.act_scope().covers("anything"));
}
}
#[test]
fn act_scope_is_subtree_aware() {
let mut e = sample_entry(None);
e.allowed_contexts = vec!["parent".into()];
assert!(e.act_scope().covers("parent"));
assert!(e.act_scope().covers("parent/child"));
assert!(!e.act_scope().covers("parentless"));
assert!(!e.is_super_admin(), "a scoped entry is never a super-admin");
}
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(),
updated_at: None,
updated_by: None,
expires_at,
}
}
}