use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use tracing::info;
use crate::audit::{self, audit};
use vta_sdk::protocols::acl_management::{
create::CreateAclResultBody, delete::DeleteAclResultBody, list::ListAclResultBody,
swap::AclSwapPresentation,
};
use crate::acl::{
AclEntry, Role, delete_acl_entry, get_acl_entry, is_acl_entry_visible, list_acl_entries,
store_acl_entry, validate_acl_modification, validate_role_assignment,
};
use crate::auth::AuthClaims;
use crate::auth::session::now_epoch;
use crate::contexts::get_context;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
use vti_common::auth::step_up::StepUpMode;
pub struct UpdateAclParams {
pub role: Option<Role>,
pub label: Option<String>,
pub allowed_contexts: Option<Vec<String>>,
pub step_up_approver: Option<String>,
pub step_up_require: Option<String>,
}
pub fn parse_step_up_require(s: Option<&str>) -> Result<Option<StepUpMode>, AppError> {
match s.map(str::trim) {
None | Some("") => Ok(None),
Some("self") => Ok(Some(StepUpMode::SelfApprove)),
Some("delegated") => Ok(Some(StepUpMode::Delegated)),
Some(other) => Err(AppError::Validation(format!(
"invalid stepUp.require '{other}': must be 'self' or 'delegated'"
))),
}
}
fn step_up_require_to_wire(m: Option<StepUpMode>) -> Option<String> {
m.map(|m| {
match m {
StepUpMode::SelfApprove => "self",
StepUpMode::Delegated => "delegated",
StepUpMode::DelegatedAny => "delegated-any",
StepUpMode::None => "none",
}
.to_string()
})
}
fn symmetric_difference_contexts(old: &[String], new: &[String]) -> Vec<String> {
use std::collections::HashSet;
let old_set: HashSet<&str> = old.iter().map(String::as_str).collect();
let new_set: HashSet<&str> = new.iter().map(String::as_str).collect();
old_set
.symmetric_difference(&new_set)
.map(|s| (*s).to_string())
.collect()
}
async fn require_contexts_exist(
contexts_ks: &KeyspaceHandle,
contexts: &[String],
) -> Result<(), AppError> {
for ctx in contexts {
if get_context(contexts_ks, ctx).await?.is_none() {
return Err(AppError::NotFound(format!(
"context '{ctx}' is not registered on this VTA — create it first via \
'vta contexts create --id {ctx}' (offline) or 'pnm contexts create' (online)"
)));
}
}
Ok(())
}
fn to_result_body(e: &AclEntry) -> CreateAclResultBody {
CreateAclResultBody {
did: e.did.clone(),
role: e.role.to_string(),
label: e.label.clone(),
allowed_contexts: e.allowed_contexts.clone(),
created_at: e.created_at,
created_by: e.created_by.clone(),
expires_at: e.expires_at,
step_up_approver: e.step_up_approver.clone(),
step_up_require: step_up_require_to_wire(e.step_up_require),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_acl(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
did: &str,
role: Role,
label: Option<String>,
allowed_contexts: Vec<String>,
expires_at: Option<u64>,
step_up_approver: Option<String>,
step_up_require: Option<String>,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
auth.require_manage()?;
validate_role_assignment(auth, &role)?;
validate_acl_modification(auth, &allowed_contexts)?;
require_contexts_exist(contexts_ks, &allowed_contexts).await?;
let step_up_require = parse_step_up_require(step_up_require.as_deref())?;
if get_acl_entry(acl_ks, did).await?.is_some() {
return Err(AppError::Conflict(format!(
"ACL entry already exists for DID: {did}"
)));
}
let entry = AclEntry::new(did, role, auth.did.clone())
.with_label(label)
.with_contexts(allowed_contexts)
.with_expires_at(expires_at)
.with_step_up_approver(step_up_approver)
.with_step_up_require(step_up_require);
store_acl_entry(acl_ks, &entry).await?;
info!(channel, caller = %auth.did, did = %entry.did, role = %entry.role, "ACL entry created");
audit!(
"acl.create",
actor = &auth.did,
resource = did,
outcome = "success"
);
let _ = audit::record(
audit_ks,
"acl.create",
&auth.did,
Some(did),
"success",
Some(channel),
None,
)
.await;
Ok(to_result_body(&entry))
}
pub async fn get_acl(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
did: &str,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
auth.require_manage()?;
let entry = get_acl_entry(acl_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(auth, &entry) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
info!(channel, did = %did, "ACL entry retrieved");
Ok(to_result_body(&entry))
}
pub async fn list_acl(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
context_filter: Option<&str>,
channel: &str,
) -> Result<ListAclResultBody, AppError> {
auth.require_manage()?;
let all_entries = list_acl_entries(acl_ks).await?;
let entries: Vec<CreateAclResultBody> = all_entries
.iter()
.filter(|e| is_acl_entry_visible(auth, e))
.filter(|e| match context_filter {
Some(ctx) => e.allowed_contexts.contains(&ctx.to_string()),
None => true,
})
.map(to_result_body)
.collect();
info!(channel, caller = %auth.did, count = entries.len(), "ACL listed");
Ok(ListAclResultBody { entries })
}
pub async fn update_acl(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
did: &str,
params: UpdateAclParams,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
auth.require_admin()?;
let mut entry = get_acl_entry(acl_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(auth, &entry) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
if let Some(ref role) = params.role {
validate_role_assignment(auth, role)?;
entry.role = role.clone();
}
if let Some(label) = params.label {
entry.label = Some(label);
}
if let Some(approver) = params.step_up_approver {
entry.step_up_approver = Some(approver);
}
if let Some(require) = params.step_up_require {
entry.step_up_require = if require.trim().is_empty() {
None
} else {
parse_step_up_require(Some(&require))?
};
}
if let Some(allowed_contexts) = params.allowed_contexts {
let changes = symmetric_difference_contexts(&entry.allowed_contexts, &allowed_contexts);
if !changes.is_empty() {
if !auth.is_super_admin() {
for ctx in &changes {
auth.require_context(ctx)?;
}
}
}
validate_acl_modification(auth, &allowed_contexts)?;
let old_set: std::collections::HashSet<&str> =
entry.allowed_contexts.iter().map(String::as_str).collect();
let added: Vec<String> = allowed_contexts
.iter()
.filter(|c| !old_set.contains(c.as_str()))
.cloned()
.collect();
require_contexts_exist(contexts_ks, &added).await?;
entry.allowed_contexts = allowed_contexts;
}
store_acl_entry(acl_ks, &entry).await?;
info!(channel, did = %did, "ACL entry updated");
audit!(
"acl.update",
actor = &auth.did,
resource = did,
outcome = "success"
);
let _ = audit::record(
audit_ks,
"acl.update",
&auth.did,
Some(did),
"success",
Some(channel),
None,
)
.await;
Ok(to_result_body(&entry))
}
pub async fn delete_acl(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
did: &str,
channel: &str,
) -> Result<DeleteAclResultBody, AppError> {
auth.require_manage()?;
if auth.did == did {
return Err(AppError::Conflict(
"cannot delete your own ACL entry".into(),
));
}
let entry = get_acl_entry(acl_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(auth, &entry) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
validate_role_assignment(auth, &entry.role)?;
delete_acl_entry(acl_ks, did).await?;
info!(channel, caller = %auth.did, did = %did, "ACL entry deleted");
audit!(
"acl.delete",
actor = &auth.did,
resource = did,
outcome = "success"
);
let _ = audit::record(
audit_ks,
"acl.delete",
&auth.did,
Some(did),
"success",
Some(channel),
None,
)
.await;
Ok(DeleteAclResultBody {
did: did.to_string(),
deleted: true,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn swap_acl(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
auth: &AuthClaims,
presentation: &str,
did_resolver: &DIDCacheClient,
vta_did: &str,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
let pres = AclSwapPresentation::new(presentation);
let claimed = pres
.peek_holder()
.map_err(|e| AppError::Authentication(format!("swap presentation: {e}")))?;
let resolved = did_resolver
.resolve(&claimed)
.await
.map_err(|e| AppError::Validation(format!("resolve new DID {claimed}: {e}")))?;
let doc = serde_json::to_value(&resolved.doc)?;
let now = now_epoch();
let verified = pres
.verify(&doc, vta_did, now)
.map_err(|e| AppError::Authentication(format!("swap presentation: {e}")))?;
let new_did = verified.holder().to_string();
if new_did == auth.did {
return Err(AppError::Conflict(
"new DID equals current DID; nothing to swap".into(),
));
}
let old = get_acl_entry(acl_ks, &auth.did)
.await?
.ok_or_else(|| AppError::NotFound(format!("no ACL entry for caller: {}", auth.did)))?;
if get_acl_entry(acl_ks, &new_did).await?.is_some() {
return Err(AppError::Conflict(format!(
"ACL entry already exists for DID: {new_did}"
)));
}
let entry = AclEntry::new(new_did.clone(), old.role.clone(), auth.did.clone())
.with_label(old.label.clone())
.with_contexts(old.allowed_contexts.clone())
.with_created_at(now)
.with_kind(old.kind.clone())
.with_capabilities(old.capabilities.clone())
.with_device(old.device.clone());
store_acl_entry(acl_ks, &entry).await?;
delete_acl_entry(acl_ks, &auth.did).await?;
info!(
channel,
old = %auth.did,
new = %new_did,
role = %entry.role,
old_expires_at = ?old.expires_at,
new_expires_at = ?entry.expires_at,
"ACL entry swapped; long-term entry is permanent (ephemeral TTL not inherited)"
);
audit!(
"acl.swap",
actor = &auth.did,
resource = &new_did,
outcome = "success"
);
let _ = audit::record(
audit_ks,
"acl.swap",
&auth.did,
Some(&new_did),
"success",
Some(channel),
None,
)
.await;
Ok(to_result_body(&entry))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AclEntry, store_acl_entry};
use crate::store::Store;
use vti_common::config::StoreConfig;
async fn fresh_store() -> (
Store,
KeyspaceHandle,
KeyspaceHandle,
KeyspaceHandle,
tempfile::TempDir,
) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let audit_ks = store.keyspace(crate::keyspaces::AUDIT).unwrap();
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
(store, acl_ks, audit_ks, contexts_ks, dir)
}
async fn seed_contexts(contexts_ks: &KeyspaceHandle, ids: &[&str]) {
use crate::contexts::{ContextRecord, store_context};
use chrono::Utc;
for (i, id) in ids.iter().enumerate() {
let now = Utc::now();
store_context(
contexts_ks,
&ContextRecord {
id: (*id).into(),
name: (*id).into(),
did: None,
description: None,
parent: None,
base_path: format!("m/26'/2'/{i}'"),
index: i as u32,
created_at: now,
updated_at: now,
},
)
.await
.unwrap();
}
}
fn ctx_admin(did: &str, contexts: &[&str]) -> AuthClaims {
AuthClaims {
did: did.into(),
role: Role::Admin,
allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
async fn seed_target(acl_ks: &KeyspaceHandle, did: &str, contexts: &[&str]) {
store_acl_entry(
acl_ks,
&AclEntry::new(did, Role::Admin, "seed")
.with_contexts(contexts.iter().map(|s| s.to_string()).collect()),
)
.await
.unwrap();
}
#[test]
fn parse_step_up_require_accepts_self_and_delegated_only() {
assert_eq!(parse_step_up_require(None).unwrap(), None);
assert_eq!(parse_step_up_require(Some("")).unwrap(), None);
assert_eq!(parse_step_up_require(Some(" ")).unwrap(), None);
assert_eq!(
parse_step_up_require(Some("self")).unwrap(),
Some(StepUpMode::SelfApprove)
);
assert_eq!(
parse_step_up_require(Some("delegated")).unwrap(),
Some(StepUpMode::Delegated)
);
assert!(parse_step_up_require(Some("delegated-any")).is_err());
assert!(parse_step_up_require(Some("none")).is_err());
assert!(parse_step_up_require(Some("nope")).is_err());
}
#[test]
fn step_up_require_round_trips_to_wire() {
assert_eq!(step_up_require_to_wire(None), None);
assert_eq!(
step_up_require_to_wire(Some(StepUpMode::SelfApprove)).as_deref(),
Some("self")
);
assert_eq!(
step_up_require_to_wire(Some(StepUpMode::Delegated)).as_deref(),
Some("delegated")
);
}
#[test]
fn symmetric_difference_handles_typical_cases() {
let s = symmetric_difference_contexts(&["a".into(), "b".into()], &["a".into(), "c".into()]);
let mut s = s;
s.sort();
assert_eq!(s, vec!["b".to_string(), "c".to_string()]);
assert!(
symmetric_difference_contexts(&["a".into(), "b".into()], &["b".into(), "a".into()])
.is_empty()
);
let s = symmetric_difference_contexts(&[], &["x".into()]);
assert_eq!(s, vec!["x".to_string()]);
let s = symmetric_difference_contexts(&["x".into()], &[]);
assert_eq!(s, vec!["x".to_string()]);
}
#[tokio::test]
async fn update_acl_rejects_shrink_across_caller_scope() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zTarget";
seed_target(&acl_ks, target, &["ctx-a", "ctx-b"]).await;
let caller = ctx_admin("did:key:zCallerA", &["ctx-a"]);
let err = update_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
target,
UpdateAclParams {
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into()]),
},
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn update_acl_allows_remove_within_caller_scope() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zTarget2";
seed_target(&acl_ks, target, &["ctx-a", "ctx-b"]).await;
let caller = ctx_admin("did:key:zCallerAB", &["ctx-a", "ctx-b"]);
let body = update_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
target,
UpdateAclParams {
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into()]),
},
"test",
)
.await
.unwrap();
assert_eq!(body.allowed_contexts, vec!["ctx-a".to_string()]);
}
#[tokio::test]
async fn update_acl_rejects_add_outside_caller_scope() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zTarget3";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let caller = ctx_admin("did:key:zCallerA", &["ctx-a"]);
let err = update_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
target,
UpdateAclParams {
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into(), "ctx-b".into()]),
},
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn create_acl_rejects_unknown_context() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-real"]).await;
let caller = AuthClaims {
did: "did:key:zSuper".into(),
role: Role::Admin,
allowed_contexts: Vec::new(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let err = create_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
"did:key:zNewAdmin",
Role::Admin,
None,
vec!["ctx-typo".into()],
None,
None,
None,
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn create_acl_accepts_known_context() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-real"]).await;
let caller = AuthClaims {
did: "did:key:zSuper".into(),
role: Role::Admin,
allowed_contexts: Vec::new(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let body = create_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
"did:key:zNewAdmin",
Role::Admin,
None,
vec!["ctx-real".into()],
None,
None,
None,
"test",
)
.await
.unwrap();
assert_eq!(body.allowed_contexts, vec!["ctx-real".to_string()]);
}
#[tokio::test]
async fn delete_acl_rejects_initiator_deleting_admin() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-shared"]).await;
let admin_target = "did:key:zAdminTarget";
seed_target(&acl_ks, admin_target, &["ctx-shared"]).await;
let caller = AuthClaims {
did: "did:key:zInitiator".into(),
role: Role::Initiator,
allowed_contexts: vec!["ctx-shared".into()],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let err = delete_acl(&acl_ks, &audit_ks, &caller, admin_target, "test")
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden, got {err:?}"
);
}
#[tokio::test]
async fn delete_acl_admin_can_delete_admin_entry() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-shared"]).await;
let admin_target = "did:key:zAdminTarget2";
seed_target(&acl_ks, admin_target, &["ctx-shared"]).await;
let caller = ctx_admin("did:key:zCallerAdmin", &["ctx-shared"]);
let body = delete_acl(&acl_ks, &audit_ks, &caller, admin_target, "test")
.await
.expect("admin-on-admin delete succeeds");
assert_eq!(body.did, admin_target);
assert!(body.deleted);
}
#[tokio::test]
async fn update_acl_rejects_adding_unknown_context() {
let (_store, acl_ks, audit_ks, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zTargetUnknown";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let caller = AuthClaims {
did: "did:key:zSuper".into(),
role: Role::Admin,
allowed_contexts: Vec::new(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let err = update_acl(
&acl_ks,
&audit_ks,
&contexts_ks,
&caller,
target,
UpdateAclParams {
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into(), "ctx-ghost".into()]),
},
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
}