use vti_common::acl::Role;
use vti_common::audit::AuditKey;
use vti_common::auth::session::now_epoch;
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, Paginated, paginate};
use vti_common::store::{Batch, KeyspaceHandle};
use super::entry::{Suspension, VtcAclEntry, decode, iter};
fn acl_key(did: &str) -> String {
format!("acl:{did}")
}
pub fn auth_role_for(entry: &VtcAclEntry) -> Result<Role, AppError> {
if entry.admin.is_administrator() {
Ok(Role::Admin)
} else {
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 = auth_role_for(&entry)?;
Ok((role, Vec::new()))
}
pub async fn require_capability(
acl_ks: &KeyspaceHandle,
did: &str,
cap: super::Capability,
resource: Option<&super::ResourceQualifier>,
) -> Result<VtcAclEntry, AppError> {
let entry = get_acl_entry(acl_ks, did).await?;
match entry {
Some(e) if e.can(cap, resource) => Ok(e),
Some(VtcAclEntry {
suspension: Some(s),
..
}) => Err(s.refusal(did)),
_ => Err(capability_refusal(did, cap, resource)),
}
}
pub async fn require_any_capability(
acl_ks: &KeyspaceHandle,
did: &str,
cap: super::Capability,
) -> Result<VtcAclEntry, AppError> {
let entry = get_acl_entry(acl_ks, did).await?;
match entry {
Some(e) if e.can_any(cap) => Ok(e),
Some(VtcAclEntry {
suspension: Some(s),
..
}) => Err(s.refusal(did)),
_ => Err(capability_refusal(did, cap, None)),
}
}
pub fn capability_refusal(
did: &str,
cap: super::Capability,
resource: Option<&super::ResourceQualifier>,
) -> AppError {
let at = resource.map(|r| format!(" at {r}")).unwrap_or_default();
AppError::Forbidden(format!(
"{did} does not hold {cap}{at} — this operation needs it (VTI-ACL-030). An \
administrator holding vtc.roles.assign can grant it with acl/update"
))
}
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) => {
let mut entry = decode(&bytes)?;
super::roles::resolve(ks, &mut entry).await?;
entry.suspension = get_suspension(ks, did).await?;
Ok(Some(entry))
}
None => Ok(None),
}
}
const SUSPENSION_PREFIX: &str = "suspended:";
fn suspension_key(did: &str) -> String {
format!("{SUSPENSION_PREFIX}{did}")
}
pub async fn get_suspension(
ks: &KeyspaceHandle,
did: &str,
) -> Result<Option<Suspension>, AppError> {
ks.get(suspension_key(did)).await
}
pub async fn put_suspension(
ks: &KeyspaceHandle,
did: &str,
s: &Suspension,
) -> Result<(), AppError> {
ks.insert(suspension_key(did), s).await?;
crate::admin_events::notify_authority();
Ok(())
}
pub async fn remove_suspension(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
ks.remove(suspension_key(did)).await?;
crate::admin_events::notify_authority();
Ok(())
}
pub async fn list_suspensions(
ks: &KeyspaceHandle,
) -> Result<std::collections::HashMap<String, Suspension>, AppError> {
let mut out = std::collections::HashMap::new();
for (k, v) in ks
.prefix_iter_raw(SUSPENSION_PREFIX.as_bytes().to_vec())
.await?
{
let did = String::from_utf8_lossy(&k[SUSPENSION_PREFIX.len()..]).into_owned();
match serde_json::from_slice::<Suspension>(&v) {
Ok(s) => {
out.insert(did, s);
}
Err(e) => tracing::warn!(%did, error = %e, "unreadable suspension marker"),
}
}
Ok(out)
}
async fn apply_suspensions(
ks: &KeyspaceHandle,
entries: &mut [VtcAclEntry],
) -> Result<(), AppError> {
let mut marks = list_suspensions(ks).await?;
if marks.is_empty() {
return Ok(());
}
for e in entries.iter_mut() {
e.suspension = marks.remove(&e.did);
}
Ok(())
}
pub async fn store_acl_entry(ks: &KeyspaceHandle, entry: &VtcAclEntry) -> Result<(), AppError> {
let mut batch = Batch::new();
batch.insert(acl_key(&entry.did), entry)?;
if !entry.resource_grants.is_empty() {
batch.insert_raw(super::resource_grant::holder_key(&entry.did), b"1".to_vec());
}
ks.apply_batch(batch).await?;
crate::admin_events::notify_authority();
Ok(())
}
const DELETE_ATTEMPTS: usize = 64;
pub async fn delete_acl_entry(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
let key = acl_key(did);
let departed_key = super::resource_grant::departed_key(did);
for _ in 0..DELETE_ATTEMPTS {
let mut batch = Batch::new();
match ks.get_raw(key.as_bytes()).await? {
Some(bytes) => {
let grants = decode(&bytes)
.map(|e| e.resource_grants)
.unwrap_or_default();
batch.require_unchanged(key.clone(), bytes);
if !grants.is_empty() {
let kept_raw = ks.get_raw(departed_key.as_bytes()).await?;
let mut kept: Vec<super::resource_grant::ResourceGrant> = match &kept_raw {
Some(raw) => serde_json::from_slice(raw)?,
None => Vec::new(),
};
match kept_raw {
Some(raw) => batch.require_unchanged(departed_key.clone(), raw),
None => batch.require_absent(departed_key.clone()),
};
kept.extend(grants);
batch.insert(departed_key.clone(), &kept)?;
}
batch.remove(key.clone());
}
None => {
batch.require_absent(key.clone());
}
}
batch.remove(super::resource_grant::holder_key(did));
if ks.apply_batch(batch).await?.is_applied() {
crate::admin_events::notify_authority();
return Ok(());
}
}
Err(AppError::Conflict(format!(
"ACL entry for {did} kept changing while it was being deleted"
)))
}
pub async fn list_acl_entries(ks: &KeyspaceHandle) -> Result<Vec<VtcAclEntry>, AppError> {
let mut entries = iter(ks).await?;
super::roles::resolve_all(ks, &mut entries).await?;
apply_suspensions(ks, &mut entries).await?;
Ok(entries)
}
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;
let mut page = paginate(pairs, cursor, limit, &audit_key.key, snapshot_id, decode)?;
super::roles::resolve_all(ks, &mut page.items).await?;
apply_suspensions(ks, &mut page.items).await?;
Ok(page)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AdminAuthority, 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,
admin: AdminAuthority::none(),
delegated_by: None,
created_at: 1,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: 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()
);
}
fn entry_with_grant(did: &str) -> VtcAclEntry {
let (capability, grade) =
crate::acl::resource_grant::capability_for(crate::git_ns::model::Right::NsAdmin);
let mut e = entry(did, VtcRole::Member);
e.resource_grants = vec![crate::acl::resource_grant::ResourceGrant {
capability,
resource: "git-ns:github.com/acme".parse().unwrap(),
grade,
delegated_by: "did:key:zG".into(),
granted_at: chrono::Utc::now(),
expires_at: None,
reason: None,
subject_was_member: true,
granter_was_member: true,
break_glass: None,
review: None,
single_admin: None,
}];
e
}
#[tokio::test]
async fn an_entry_and_its_index_commit_as_one_journal_record() {
let (ks, _dir) = temp_ks().await;
let seq = || ks.journal_seqno().unwrap();
let before = seq();
store_acl_entry(&ks, &entry_with_grant("did:key:zJ"))
.await
.unwrap();
assert_eq!(seq() - before, 1, "store_acl_entry");
let before = seq();
delete_acl_entry(&ks, "did:key:zJ").await.unwrap();
assert_eq!(seq() - before, 1, "delete_acl_entry");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn racing_deletes_keep_departed_grants_exactly_once() {
let (ks, _dir) = temp_ks().await;
for round in 0..40 {
let did = format!("did:key:zDeparting{round}");
store_acl_entry(&ks, &entry_with_grant(&did)).await.unwrap();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(8));
let mut tasks = Vec::new();
for _ in 0..8 {
let (ks, barrier, did) = (ks.clone(), barrier.clone(), did.clone());
tasks.push(tokio::spawn(async move {
barrier.wait().await;
delete_acl_entry(&ks, &did).await.unwrap();
}));
}
for t in tasks {
t.await.unwrap();
}
let kept: usize = crate::acl::resource_grant::departed(&ks)
.await
.unwrap()
.into_iter()
.filter(|(d, _)| *d == did)
.map(|(_, g)| g.len())
.sum();
assert_eq!(kept, 1, "one deleted entry's grant kept {kept} times");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn the_holder_index_tracks_the_entry_under_races() {
let (ks, _dir) = temp_ks().await;
for round in 0..40 {
let did = format!("did:key:zIndexed{round}");
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(8));
let mut tasks = Vec::new();
for i in 0..8 {
let (ks, barrier, did) = (ks.clone(), barrier.clone(), did.clone());
tasks.push(tokio::spawn(async move {
barrier.wait().await;
if i % 2 == 0 {
store_acl_entry(&ks, &entry_with_grant(&did)).await.unwrap();
} else {
delete_acl_entry(&ks, &did).await.unwrap();
}
}));
}
for t in tasks {
t.await.unwrap();
}
let held = get_acl_entry(&ks, &did)
.await
.unwrap()
.is_some_and(|e| !e.resource_grants.is_empty());
let indexed = crate::acl::resource_grant::indexed_holders(&ks)
.await
.unwrap()
.contains(&did);
assert_eq!(
held, indexed,
"entry holds grants: {held}, indexed: {indexed}"
);
}
}
#[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 any_administrative_role_maps_to_the_admin_session_role() {
use crate::acl::capability::AdminRole;
for role in AdminRole::BUILT_IN {
let mut e = entry("did:key:zA", VtcRole::Member);
e.admin = AdminAuthority::for_role(role.clone());
assert_eq!(auth_role_for(&e).unwrap(), Role::Admin, "{role}");
}
}
#[test]
fn entries_without_an_administrative_role_are_cleanly_forbidden() {
use axum::response::IntoResponse;
for role in [
VtcRole::Admin,
VtcRole::Moderator,
VtcRole::Issuer,
VtcRole::Member,
VtcRole::custom("editor").unwrap(),
] {
let err = auth_role_for(&entry("did:key:zA", role.clone()))
.expect_err("no administrative 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) =
auth_role_for(&entry("did:key:zA", 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_an_administrator_with_no_contexts() {
let (ks, _dir) = temp_ks().await;
let mut e = entry("did:key:zAdmin", VtcRole::Member);
e.admin = AdminAuthority::for_role(crate::acl::capability::AdminRole::Auditor);
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::<String>::new(),
"a VTC entry holds no contexts"
);
}
#[tokio::test]
async fn require_capability_reads_the_live_entry() {
use crate::acl::Capability;
let (ks, _dir) = temp_ks().await;
let mut e = entry("did:key:zAud", VtcRole::Member);
e.admin = AdminAuthority::for_role(crate::acl::capability::AdminRole::Auditor);
store_acl_entry(&ks, &e).await.unwrap();
assert!(
require_capability(&ks, "did:key:zAud", Capability::AuditRead, None)
.await
.is_ok()
);
assert!(matches!(
require_capability(&ks, "did:key:zAud", Capability::ConfigAdmin, None).await,
Err(AppError::Forbidden(_))
));
assert!(matches!(
require_capability(&ks, "did:key:zNobody", Capability::AuditRead, None).await,
Err(AppError::Forbidden(_))
));
}
#[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.admin = AdminAuthority::community_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(_))
));
}
}