use vti_common::acl::Role;
use vti_common::audit::AuditKey;
use vti_common::auth::extractor::AuthClaims;
use vti_common::auth::session::now_epoch;
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 fn map_vtc_role_to_auth_role(role: &VtcRole) -> Result<Role, AppError> {
match role {
VtcRole::Admin => Ok(Role::Admin),
VtcRole::Moderator | VtcRole::Issuer | VtcRole::Member | VtcRole::Custom(_) => Err(
AppError::Forbidden("DID is not permitted to authenticate on this VTC".into()),
),
}
}
pub async fn resolve_auth_role(
acl_ks: &KeyspaceHandle,
did: &str,
) -> Result<(Role, Vec<String>), AppError> {
let entry = get_acl_entry(acl_ks, did)
.await?
.ok_or_else(|| AppError::Forbidden(format!("DID not in ACL: {did}")))?;
if entry.is_expired(now_epoch()) {
return Err(AppError::Forbidden(format!("ACL entry expired: {did}")));
}
let role = map_vtc_role_to_auth_role(&entry.role)?;
Ok((role, entry.allowed_contexts))
}
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());
}
#[test]
fn admin_maps_to_admin_role() {
assert_eq!(
map_vtc_role_to_auth_role(&VtcRole::Admin).unwrap(),
Role::Admin
);
}
#[test]
fn non_admin_roles_are_cleanly_forbidden() {
use axum::response::IntoResponse;
for role in [
VtcRole::Moderator,
VtcRole::Issuer,
VtcRole::Member,
VtcRole::custom("editor").unwrap(),
] {
let err = map_vtc_role_to_auth_role(&role)
.expect_err("non-admin role must not map to an auth role");
assert_eq!(
err.into_response().status(),
axum::http::StatusCode::FORBIDDEN,
"{role} must yield 403"
);
}
}
#[test]
fn forbidden_message_carries_no_serde_internals_or_role_name() {
let AppError::Forbidden(msg) = map_vtc_role_to_auth_role(&VtcRole::Moderator).unwrap_err()
else {
panic!("expected Forbidden");
};
assert!(!msg.contains("variant"), "must not leak serde text: {msg}");
assert!(
!msg.contains("moderator"),
"must not enumerate the role to an unauth caller: {msg}"
);
}
#[tokio::test]
async fn resolve_auth_role_admits_admin_with_contexts() {
let (ks, _dir) = temp_ks().await;
let mut e = entry("did:key:zAdmin", VtcRole::Admin);
e.allowed_contexts = vec!["ctx-a".into()];
store_acl_entry(&ks, &e).await.unwrap();
let (role, contexts) = resolve_auth_role(&ks, "did:key:zAdmin").await.unwrap();
assert_eq!(role, Role::Admin);
assert_eq!(contexts, vec!["ctx-a".to_string()]);
}
#[tokio::test]
async fn resolve_auth_role_forbids_non_admin_absent_and_expired() {
let (ks, _dir) = temp_ks().await;
store_acl_entry(&ks, &entry("did:key:zMod", VtcRole::Moderator))
.await
.unwrap();
assert!(matches!(
resolve_auth_role(&ks, "did:key:zMod").await,
Err(AppError::Forbidden(_))
));
assert!(matches!(
resolve_auth_role(&ks, "did:key:zNobody").await,
Err(AppError::Forbidden(_))
));
let mut expired = entry("did:key:zStale", VtcRole::Admin);
expired.expires_at = Some(1); store_acl_entry(&ks, &expired).await.unwrap();
assert!(matches!(
resolve_auth_role(&ks, "did:key:zStale").await,
Err(AppError::Forbidden(_))
));
}
}