use vti_common::audit::AuditKey;
use vti_common::auth::extractor::AuthClaims;
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, Paginated, paginate};
use vti_common::store::KeyspaceHandle;
use super::VtcRole;
use super::entry::{VtcAclEntry, decode, iter};
fn acl_key(did: &str) -> String {
format!("acl:{did}")
}
pub async fn get_acl_entry(
ks: &KeyspaceHandle,
did: &str,
) -> Result<Option<VtcAclEntry>, AppError> {
let key = acl_key(did);
let raw = ks.get_raw(key.as_bytes()).await?;
match raw {
Some(bytes) => Ok(Some(decode(&bytes)?)),
None => Ok(None),
}
}
pub async fn store_acl_entry(ks: &KeyspaceHandle, entry: &VtcAclEntry) -> Result<(), AppError> {
ks.insert(acl_key(&entry.did), entry).await
}
pub async fn delete_acl_entry(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
ks.remove(acl_key(did)).await
}
pub fn validate_vtc_role_assignment(
caller: &AuthClaims,
target_role: &VtcRole,
) -> Result<(), AppError> {
use vti_common::acl::Role as ViRole;
if matches!(
caller.role,
ViRole::Monitor | ViRole::Reader | ViRole::Application
) {
return Err(AppError::Forbidden(
"insufficient role to assign roles".into(),
));
}
if matches!(target_role, VtcRole::Admin) && caller.role != ViRole::Admin {
return Err(AppError::Forbidden(
"only admins can assign the admin role".into(),
));
}
Ok(())
}
pub async fn list_acl_entries(ks: &KeyspaceHandle) -> Result<Vec<VtcAclEntry>, AppError> {
iter(ks).await
}
pub async fn list_acl_entries_paginated(
ks: &KeyspaceHandle,
audit_key: &AuditKey,
cursor: Option<&Cursor>,
limit: usize,
) -> Result<Paginated<VtcAclEntry>, AppError> {
let mut pairs = ks.prefix_iter_raw(b"acl:".to_vec()).await?;
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let snapshot_id: u64 = pairs.len() as u64;
paginate(pairs, cursor, limit, &audit_key.key, snapshot_id, decode)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::VtcRole;
use vti_common::audit::AuditKeyStore;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
let ks = store.keyspace("acl").expect("ks");
(ks, dir)
}
fn entry(did: &str, role: VtcRole) -> VtcAclEntry {
VtcAclEntry {
did: did.into(),
role,
label: None,
allowed_contexts: vec![],
created_at: 1,
created_by: "did:key:vtc-install".into(),
expires_at: None,
}
}
#[tokio::test]
async fn store_then_get_round_trip() {
let (ks, _dir) = temp_ks().await;
let e = entry("did:key:zMember1", VtcRole::Member);
store_acl_entry(&ks, &e).await.unwrap();
let got = get_acl_entry(&ks, "did:key:zMember1")
.await
.unwrap()
.expect("entry present");
assert_eq!(got, e);
}
#[tokio::test]
async fn get_returns_none_for_unknown_did() {
let (ks, _dir) = temp_ks().await;
assert!(
get_acl_entry(&ks, "did:key:zNobody")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn delete_is_idempotent() {
let (ks, _dir) = temp_ks().await;
let e = entry("did:key:zDelete", VtcRole::Member);
store_acl_entry(&ks, &e).await.unwrap();
delete_acl_entry(&ks, "did:key:zDelete").await.unwrap();
delete_acl_entry(&ks, "did:key:zDelete").await.unwrap();
assert!(
get_acl_entry(&ks, "did:key:zDelete")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn list_acl_entries_returns_every_row() {
let (ks, _dir) = temp_ks().await;
for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
store_acl_entry(&ks, &entry(did, VtcRole::Member))
.await
.unwrap();
}
let listed = list_acl_entries(&ks).await.unwrap();
assert_eq!(listed.len(), 3);
}
#[tokio::test]
async fn paginated_walks_the_keyspace() {
let (ks, _dir) = temp_ks().await;
let dir = tempfile::tempdir().unwrap();
let store2 = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let audit_key_ks = store2.keyspace("audit_key").unwrap();
let key_store = AuditKeyStore::new(audit_key_ks);
let audit_key = key_store.ensure_initial(&[0xAB; 32]).await.unwrap();
for did in [
"did:key:zA",
"did:key:zB",
"did:key:zC",
"did:key:zD",
"did:key:zE",
] {
store_acl_entry(&ks, &entry(did, VtcRole::Member))
.await
.unwrap();
}
let page1 = list_acl_entries_paginated(&ks, &audit_key, None, 2)
.await
.unwrap();
assert_eq!(page1.items.len(), 2);
assert!(page1.next_cursor.is_some());
assert_eq!(page1.items[0].did, "did:key:zA");
assert_eq!(page1.items[1].did, "did:key:zB");
let cursor1 =
Cursor::decode(page1.next_cursor.as_deref().unwrap(), &audit_key.key).unwrap();
let page2 = list_acl_entries_paginated(&ks, &audit_key, Some(&cursor1), 2)
.await
.unwrap();
assert_eq!(page2.items.len(), 2);
assert_eq!(page2.items[0].did, "did:key:zC");
assert_eq!(page2.items[1].did, "did:key:zD");
let cursor2 =
Cursor::decode(page2.next_cursor.as_deref().unwrap(), &audit_key.key).unwrap();
let page3 = list_acl_entries_paginated(&ks, &audit_key, Some(&cursor2), 2)
.await
.unwrap();
assert_eq!(page3.items.len(), 1);
assert_eq!(page3.items[0].did, "did:key:zE");
assert!(page3.next_cursor.is_none());
}
}