use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use tracing::info;
use crate::audit::{self, audit};
use vta_sdk::protocols::acl_management::{
create::{CreateAclResponseBody, CreateAclResultBody},
delete::DeleteAclResultBody,
get::GetAclResultBody,
list::ListAclResultBody,
swap::AclSwapPresentation,
};
use crate::acl::{
AclEntry, ApproveScope, Capability, ContextDirection, Role, acl_entry_matches_context,
capabilities_beyond_role, delete_acl_entry, effective_capabilities, get_acl_entry,
is_acl_entry_auditable, is_acl_entry_visible, list_acl_entries, store_acl_entry,
update_acl_entry_versioned, validate_acl_modification, validate_additive_capability_grant,
validate_approve_scope_grant, 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 approve_scope: Option<ApproveScope>,
pub expires_at: Option<u64>,
pub reason: Option<String>,
pub capabilities: Option<Vec<Capability>>,
pub allowed_keys: Option<Option<std::collections::BTreeSet<String>>>,
}
pub fn parse_step_up_require(s: Option<&str>) -> Result<Option<StepUpMode>, AppError> {
match s.map(str::trim) {
None | Some("") => Ok(None),
Some(other) => Err(AppError::Validation(format!(
"stepUp.require ('{other}') is no longer honoured and this VTA will not \
store it — a per-entry override raised an `[auth.step_up]` floor, and \
the floors have been retired. Gating is expressed as a rule: \
`pnm approvals require <task-uri> --reauth` (or `--consent`), which \
`pnm approvals list` can show you. Re-send this request without the \
field."
))),
}
}
pub(crate) fn acl_entry_can_confer(entry: &AclEntry, ctx: &str) -> bool {
if entry.approve_scope.covers(ctx) {
return true;
}
let claims = AuthClaims {
did: entry.did.clone(),
role: entry.role.clone(),
allowed_contexts: entry.allowed_contexts.clone(),
..Default::default()
};
claims.role == Role::Admin && claims.has_context_access(ctx)
}
pub fn approve_scope_from_wire(all: bool, contexts: Vec<String>) -> ApproveScope {
if all {
ApproveScope::All
} else if !contexts.is_empty() {
ApproveScope::Contexts(contexts)
} else {
ApproveScope::None
}
}
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 not_manageable(auth: &AuthClaims, entry: &AclEntry, did: &str, verb: &str) -> AppError {
if is_acl_entry_auditable(auth, entry) {
AppError::Forbidden(format!(
"{did} is visible to you because it may confer in a context you administer, \
but it acts outside your contexts — only an admin of the contexts it acts in \
can {verb} it"
))
} else {
AppError::NotFound(format!("ACL entry not found for DID: {did}"))
}
}
async fn caller_entry(acl_ks: &KeyspaceHandle, auth: &AuthClaims) -> Result<AclEntry, AppError> {
match get_acl_entry(acl_ks, &auth.did).await? {
Some(entry) if entry.is_expired(now_epoch()) => Err(AppError::Forbidden(format!(
"your ACL entry ({}) has expired; an expired entry confers no authority to grant \
(VTI-ACL-004)",
auth.did
))),
Some(entry) => Ok(entry),
None => Err(AppError::Forbidden(format!(
"{} has no ACL entry of its own, so there is no authority to bound this change by \
(VTI-ACL-001, VTI-ACL-053)",
auth.did
))),
}
}
fn refuse_self_modification(auth: &AuthClaims, did: &str, verb: &str) -> Result<(), AppError> {
if auth.did == did {
return Err(AppError::Forbidden(format!(
"you cannot {verb} your own ACL entry (VTI-ACL-052) — another administrator whose \
scope covers it must make this change; to move your entry to a new key, rotate \
with acl/swap-key"
)));
}
Ok(())
}
fn caller_covers_entry(auth: &AuthClaims, entry: &AclEntry) -> bool {
use vti_common::acl::ActScope;
if auth.is_super_admin() {
return true;
}
let act = entry.act_scope();
let acts = match &act {
ActScope::Contexts(cs) => cs.iter().all(|c| auth.has_context_access(c)),
ActScope::None => true,
ActScope::All => false,
};
let approves = match &entry.approve_scope {
ApproveScope::None => true,
ApproveScope::Contexts(cs) => cs.iter().all(|c| auth.has_context_access(c)),
ApproveScope::All => false,
};
let names_something =
!matches!(act, ActScope::None) || !matches!(entry.approve_scope, ApproveScope::None);
acts && approves && names_something
}
fn not_covered(did: &str, verb: &str) -> AppError {
AppError::Forbidden(format!(
"{did} holds authority outside your contexts — only an administrator whose scope covers \
every context it acts or approves in can {verb} it"
))
}
fn validate_within_caller(
auth: &AuthClaims,
caller: &AclEntry,
target: &AclEntry,
) -> Result<(), AppError> {
let ceiling = effective_capabilities(&auth.role, &caller.capabilities);
let beyond: Vec<Capability> = effective_capabilities(&target.role, &target.capabilities)
.into_iter()
.filter(|c| !vti_common::acl::is_additive(*c) && !ceiling.contains(c))
.collect();
if !beyond.is_empty() {
return Err(AppError::Forbidden(format!(
"this entry would hold {beyond:?}, which your own entry does not — you can grant only \
capabilities you hold (VTI-ACL-053); narrow its capabilities to a subset of yours"
)));
}
if let Some(mine) = &caller.allowed_keys {
match &target.allowed_keys {
None => {
return Err(AppError::Forbidden(
"your entry is narrowed to a set of keys, so you cannot write an entry with \
no key filter — name the keys it may use, within your own (VTI-ACL-053)"
.into(),
));
}
Some(theirs) => {
let outside: Vec<&String> = theirs.difference(mine).collect();
if !outside.is_empty() {
return Err(AppError::Forbidden(format!(
"keys {outside:?} are outside your own key filter (VTI-ACL-053)"
)));
}
}
}
}
if let Some(mine) = caller.expires_at {
match target.expires_at {
None => {
return Err(AppError::Forbidden(format!(
"your entry expires at {mine}, so you cannot write a permanent one — give it \
an expiry no later than yours (VTI-ACL-053)"
)));
}
Some(theirs) if theirs > mine => {
return Err(AppError::Forbidden(format!(
"this entry would expire at {theirs}, after your own ({mine}) — an entry you \
write cannot outlive your authority (VTI-ACL-053)"
)));
}
Some(_) => {}
}
}
if let ApproveScope::Contexts(cs) = &target.approve_scope {
let acting = AclEntry {
role: auth.role.clone(),
allowed_contexts: auth.allowed_contexts.clone(),
..caller.clone()
};
let cannot: Vec<&String> = cs
.iter()
.filter(|c| !acl_entry_can_confer(&acting, c))
.collect();
if !cannot.is_empty() {
return Err(AppError::Forbidden(format!(
"you cannot confer approve authority in {cannot:?}: you neither approve nor \
administer there yourself (VTI-ACL-042)"
)));
}
}
Ok(())
}
fn mint_handoff(
auth: &AuthClaims,
me: &AclEntry,
subject: &str,
expires_at: Option<u64>,
) -> Result<vti_common::acl::HandOff, AppError> {
if subject == auth.did {
return Err(AppError::Forbidden(
"you cannot mark your own entry as a hand-off (VTI-ACL-054)".into(),
));
}
if me.handoff.is_some() {
return Err(AppError::Forbidden(
"your own entry is a hand-off, so you cannot mark another one (VTI-ACL-054) — \
an administrator whose entry carries no marker must grant it"
.into(),
));
}
if expires_at.is_none() {
return Err(AppError::Validation(
"a hand-off entry must carry an expiry (VTI-ACL-054), so an unused bootstrap \
grant lapses — give it one, e.g. `--expires 1h`"
.into(),
));
}
Ok(vti_common::acl::HandOff {
granted_by: auth.did.clone(),
granted_at: now_epoch(),
bound: vti_common::acl::HandOffBound {
role: auth.role.clone(),
allowed_contexts: auth.allowed_contexts.clone(),
capabilities: me.capabilities.clone(),
approve_scope: me.approve_scope.clone(),
allowed_keys: me.allowed_keys.clone(),
expires_at: me.expires_at,
},
})
}
pub async fn holds_handoff(acl_ks: &KeyspaceHandle, did: &str) -> Result<bool, AppError> {
Ok(get_acl_entry(acl_ks, did)
.await?
.is_some_and(|e| e.handoff.is_some()))
}
#[derive(Debug, Clone)]
pub struct HandOffSuccessor {
pub did: String,
pub role: Role,
pub label: Option<String>,
pub allowed_contexts: Vec<String>,
pub capabilities: Vec<Capability>,
}
static HANDOFF_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn exercise_handoff(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
successor: HandOffSuccessor,
channel: &str,
context_id: Option<&str>,
) -> Result<CreateAclResultBody, AppError> {
let _serialised = HANDOFF_LOCK.lock().await;
let now = now_epoch();
let Some((me, expected)) = vti_common::acl::get_acl_entry_with_bytes(acl_ks, &auth.did).await?
else {
return Err(AppError::Forbidden(format!(
"{} has no ACL entry, so there is no hand-off to exercise — it may already have \
been exercised (VTI-ACL-055)",
auth.did
)));
};
let Some(marker) = me.handoff.clone() else {
return Err(AppError::Forbidden(format!(
"{} carries no hand-off marker, so it cannot write a successor that outlives it \
(VTI-ACL-058) — the granter must mark the entry as a hand-off when creating it",
auth.did
)));
};
if me.is_expired(now) {
return Err(AppError::Forbidden(format!(
"the hand-off entry for {} has expired; an expired marker cannot be exercised \
(VTI-ACL-055)",
auth.did
)));
}
if marker.bound.expires_at.is_some_and(|t| t <= now) {
return Err(AppError::Forbidden(
"the granter's authority this hand-off is bounded by has expired (VTI-ACL-055)".into(),
));
}
if successor.did == auth.did {
return Err(AppError::Validation(
"a hand-off's successor must be a different subject".into(),
));
}
let granter = AuthClaims {
did: marker.granted_by.clone(),
role: marker.bound.role.clone(),
allowed_contexts: marker.bound.allowed_contexts.clone(),
..auth.clone()
};
let granter_entry = AclEntry::new(&marker.granted_by, marker.bound.role.clone(), "handoff")
.with_contexts(marker.bound.allowed_contexts.clone())
.with_capabilities(marker.bound.capabilities.clone())
.with_approve_scope(marker.bound.approve_scope.clone())
.with_allowed_keys(marker.bound.allowed_keys.clone())
.with_expires_at(marker.bound.expires_at);
let beyond = capabilities_beyond_role(&successor.role, &successor.capabilities);
if !beyond.is_empty() {
return Err(AppError::Validation(format!(
"role {} does not carry {beyond:?}",
successor.role
)));
}
require_contexts_exist(contexts_ks, &successor.allowed_contexts).await?;
let entry = AclEntry::new(&successor.did, successor.role.clone(), auth.did.clone())
.with_label(successor.label.clone())
.with_contexts(successor.allowed_contexts.clone())
.with_capabilities(successor.capabilities.clone())
.with_allowed_keys(me.allowed_keys.clone())
.with_expires_at(marker.bound.expires_at);
validate_role_assignment(auth, &entry.role)?;
validate_acl_modification(auth, &entry.role, &entry.allowed_contexts)?;
let me_unexpiring = AclEntry {
expires_at: None,
..me.clone()
};
validate_within_caller(auth, &me_unexpiring, &entry)?;
let additive: Vec<Capability> = entry
.capabilities
.iter()
.copied()
.filter(|c| vti_common::acl::is_additive(*c))
.collect();
if let Some(c) = additive.iter().find(|c| !me.capabilities.contains(c)) {
return Err(AppError::Forbidden(format!(
"the successor would hold {c:?}, which the hand-off entry does not (VTI-ACL-055)"
)));
}
validate_role_assignment(&granter, &entry.role)
.and_then(|()| validate_acl_modification(&granter, &entry.role, &entry.allowed_contexts))
.and_then(|()| validate_additive_capability_grant(&granter, &additive))
.and_then(|()| validate_within_caller(&granter, &granter_entry, &entry))
.map_err(|e| {
AppError::Forbidden(format!(
"the successor exceeds the authority {} held when it marked this hand-off \
(VTI-ACL-055): {e}",
marker.granted_by
))
})?;
let detail = format!("hand-off granted by {}", marker.granted_by);
vta_audit::record_with_detail(
audit,
"acl.handoff",
&auth.did,
Some(&entry.did),
"committing",
Some(channel),
context_id,
Some(&detail),
)
.await?;
let outcome =
vti_common::acl::move_acl_entry_if_unchanged(acl_ks, &auth.did, expected, &entry).await;
let refusal = match outcome {
Ok(crate::store::MoveOutcome::Moved) => None,
Ok(crate::store::MoveOutcome::SourceMissing) => Some(AppError::Conflict(
"the hand-off was exercised or revoked concurrently (VTI-ACL-056)".into(),
)),
Ok(crate::store::MoveOutcome::SourceChanged) => Some(AppError::Conflict(
"the hand-off entry changed while the rollover was prepared; retry (VTI-ACL-056)"
.into(),
)),
Ok(crate::store::MoveOutcome::TargetExists) => Some(AppError::Conflict(format!(
"ACL entry already exists for DID: {}",
entry.did
))),
Err(e) => Some(e),
};
if let Some(e) = refusal {
audit::record_with_detail_best_effort(
audit,
"acl.handoff",
&auth.did,
Some(&entry.did),
"failure",
Some(channel),
context_id,
Some(&detail),
)
.await;
return Err(e);
}
info!(
channel,
from = %auth.did,
to = %entry.did,
granted_by = %marker.granted_by,
expires_at = ?entry.expires_at,
"hand-off exercised; marked entry replaced by its successor (VTI-ACL-056)"
);
audit!(
"acl.handoff",
actor = &auth.did,
resource = &entry.did,
outcome = "success"
);
audit::record_with_detail_best_effort(
audit,
"acl.handoff",
&auth.did,
Some(&entry.did),
"success",
Some(channel),
context_id,
Some(&detail),
)
.await;
Ok(to_result_body(&entry))
}
pub fn parse_capability_names(names: &[String]) -> Result<Vec<Capability>, AppError> {
names
.iter()
.map(|n| {
serde_json::from_value::<Capability>(serde_json::Value::String(n.clone())).map_err(
|_| {
AppError::Validation(format!(
"unknown capability `{n}`; this VTA does not recognise it, and narrowing \
an entry to a capability nobody enforces would grant more than intended"
))
},
)
})
.collect()
}
fn to_result_body(e: &AclEntry) -> CreateAclResultBody {
let (approve_all_contexts, approve_contexts) = match &e.approve_scope {
ApproveScope::All => (true, Vec::new()),
ApproveScope::Contexts(cs) => (false, cs.clone()),
ApproveScope::None => (false, Vec::new()),
};
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),
approve_all_contexts,
approve_contexts,
allowed_keys: e
.allowed_keys
.as_ref()
.map(|keys| keys.iter().cloned().collect()),
handoff: e.handoff.is_some(),
capabilities: e
.capabilities
.iter()
.filter_map(|c| serde_json::to_value(c).ok())
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
}
}
fn validate_allowed_keys(
allowed_keys: Option<&std::collections::BTreeSet<String>>,
) -> Result<(), AppError> {
if let Some(keys) = allowed_keys {
for key in keys {
if key.trim().is_empty() {
return Err(AppError::Validation(
"allowedKeys must not contain an empty key id — pass an empty \
list to authorize no keys, or omit the member for no filter"
.into(),
));
}
}
}
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct CreateAclParams {
pub did: String,
pub role: Role,
pub label: Option<String>,
pub allowed_contexts: Vec<String>,
pub expires_at: Option<u64>,
pub step_up_approver: Option<String>,
pub step_up_require: Option<String>,
pub approve_scope: ApproveScope,
pub allowed_keys: Option<std::collections::BTreeSet<String>>,
pub capabilities: Vec<Capability>,
pub handoff: bool,
}
pub async fn create_acl(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
params: CreateAclParams,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
let CreateAclParams {
did,
role,
label,
allowed_contexts,
expires_at,
step_up_approver,
step_up_require,
approve_scope,
allowed_keys,
capabilities,
handoff,
} = params;
let did = did.as_str();
auth.require_manage()?;
validate_role_assignment(auth, &role)?;
validate_acl_modification(auth, &role, &allowed_contexts)?;
validate_allowed_keys(allowed_keys.as_ref())?;
let beyond = capabilities_beyond_role(&role, &capabilities);
if !beyond.is_empty() {
return Err(AppError::Validation(format!(
"role {role} does not carry {beyond:?}; an entry's capabilities can only \
narrow what its role allows, never widen it"
)));
}
validate_additive_capability_grant(auth, &capabilities)?;
validate_approve_scope_grant(auth, &approve_scope)?;
require_contexts_exist(contexts_ks, &allowed_contexts).await?;
if let ApproveScope::Contexts(cs) = &approve_scope {
require_contexts_exist(contexts_ks, cs).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)
.with_approve_scope(approve_scope)
.with_allowed_keys(allowed_keys)
.with_capabilities(capabilities);
let me = caller_entry(acl_ks, auth).await?;
validate_within_caller(auth, &me, &entry)?;
let entry = if handoff {
entry.with_handoff(Some(mint_handoff(auth, &me, did, expires_at)?))
} else {
entry
};
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"
);
audit::record_best_effort(
audit,
"acl.create",
&auth.did,
Some(did),
"success",
Some(channel),
None,
)
.await;
Ok(to_result_body(&entry))
}
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_auditable(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))
}
async fn list_acl(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
context_filter: Option<&str>,
direction: ContextDirection,
channel: &str,
) -> Result<Vec<CreateAclResultBody>, AppError> {
auth.require_manage()?;
if context_filter.is_none() && direction != ContextDirection::default() {
return Err(AppError::Validation(format!(
"`direction={direction}` filters a context and there is no context to filter — \
pass a context as well, or drop the direction to list every visible entry"
)));
}
let all_entries = list_acl_entries(acl_ks).await?;
let entries: Vec<CreateAclResultBody> = all_entries
.iter()
.filter(|e| is_acl_entry_auditable(auth, e))
.filter(|e| match context_filter {
Some(ctx) => acl_entry_matches_context(e, ctx, direction),
None => true,
})
.map(to_result_body)
.collect();
info!(
channel,
caller = %auth.did,
context = context_filter.unwrap_or("-"),
direction = %direction,
count = entries.len(),
"ACL listed"
);
Ok(entries)
}
async fn update_acl(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
did: &str,
params: UpdateAclParams,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
auth.require_admin()?;
refuse_self_modification(auth, did, "update")?;
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(not_manageable(auth, &entry, did, "update"));
}
if !caller_covers_entry(auth, &entry) {
return Err(not_covered(did, "update"));
}
let me = caller_entry(acl_ks, auth).await?;
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(capabilities) = params.capabilities {
let beyond = capabilities_beyond_role(&entry.role, &capabilities);
if !beyond.is_empty() {
return Err(AppError::Validation(format!(
"role {} does not carry {beyond:?}; an entry's capabilities can only \
narrow what its role allows, never widen it",
entry.role
)));
}
validate_additive_capability_grant(auth, &capabilities)?;
entry.capabilities = capabilities;
}
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, &entry.role, &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;
}
if let Some(replacement) = params.allowed_keys {
validate_allowed_keys(replacement.as_ref())?;
entry.allowed_keys = replacement;
}
if let Some(scope) = params.approve_scope {
validate_approve_scope_grant(auth, &scope)?;
if let ApproveScope::Contexts(ref cs) = scope {
require_contexts_exist(contexts_ks, cs).await?;
}
entry.approve_scope = scope;
}
if let Some(expires_at) = params.expires_at {
entry.expires_at = Some(expires_at);
}
validate_role_assignment(auth, &entry.role)?;
validate_acl_modification(auth, &entry.role, &entry.allowed_contexts)?;
validate_within_caller(auth, &me, &entry)?;
store_acl_entry(acl_ks, &entry).await?;
info!(channel, did = %did, "ACL entry updated");
audit!(
"acl.update",
actor = &auth.did,
resource = did,
outcome = "success"
);
audit::record_with_detail_best_effort(
audit,
"acl.update",
&auth.did,
Some(did),
"success",
Some(channel),
None,
params.reason.as_deref(),
)
.await;
Ok(to_result_body(&entry))
}
#[allow(clippy::too_many_arguments)]
async fn change_role(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
subject: &str,
from_role: &str,
to_role: &str,
reason: Option<&str>,
channel: &str,
) -> Result<CreateAclResultBody, AppError> {
auth.require_admin()?;
refuse_self_modification(auth, subject, "change the role of")?;
let mut entry = get_acl_entry(acl_ks, subject)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {subject}")))?;
if !is_acl_entry_visible(auth, &entry) {
return Err(not_manageable(auth, &entry, subject, "change-role"));
}
if !caller_covers_entry(auth, &entry) {
return Err(not_covered(subject, "change-role"));
}
let parse = |raw: &str| -> Result<Role, AppError> {
Role::parse(raw).map_err(|_| {
AppError::Validation(format!(
"role not recognized: {raw}; expected one of admin, initiator, \
application, reader, monitor"
))
})
};
let from = parse(from_role)?;
let to = parse(to_role)?;
if entry.role != from {
return Err(AppError::Conflict(format!(
"role of {subject} is {}, not {from} — the change was based on stale state. \
Re-read the entry and retry with the current role",
entry.role,
)));
}
validate_role_assignment(auth, &to)?;
validate_acl_modification(auth, &to, &entry.allowed_contexts)?;
let expected_version = entry.version;
entry.role = to;
let me = caller_entry(acl_ks, auth).await?;
validate_within_caller(auth, &me, &entry)?;
update_acl_entry_versioned(acl_ks, entry.clone(), expected_version).await?;
info!(channel, did = %subject, from = %from_role, to = %to_role, "ACL role changed");
audit!(
"acl.change_role",
actor = &auth.did,
resource = subject,
outcome = "success"
);
audit::record_with_detail_best_effort(
audit,
"acl.change_role",
&auth.did,
Some(subject),
"success",
Some(channel),
None,
reason,
)
.await;
Ok(to_result_body(&entry))
}
pub async fn may_manage_subject(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
subject: &str,
) -> Result<bool, AppError> {
if auth.did == subject || auth.is_super_admin() {
return Ok(true);
}
if auth.require_manage().is_err() {
return Ok(false);
}
let Some(entry) = get_acl_entry(acl_ks, subject).await? else {
return Ok(false);
};
Ok(is_acl_entry_visible(auth, &entry) && validate_role_assignment(auth, &entry.role).is_ok())
}
pub async fn delete_acl(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
did: &str,
channel: &str,
) -> Result<(), 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(not_manageable(auth, &entry, did, "delete"));
}
if !caller_covers_entry(auth, &entry) {
return Err(not_covered(did, "delete"));
}
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"
);
audit::record_best_effort(
audit,
"acl.delete",
&auth.did,
Some(did),
"success",
Some(channel),
None,
)
.await;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn swap_acl(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
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 old.is_expired(now) {
return Err(AppError::Forbidden(format!(
"ACL entry for {} has expired; an expired entry cannot be rotated",
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 mut entry = old.clone();
entry.did = new_did.clone();
entry.handoff = None;
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,
expires_at = ?entry.expires_at,
"ACL entry swapped; authority preserved exactly (VTI-CLT-029)"
);
audit!(
"acl.swap",
actor = &auth.did,
resource = &new_did,
outcome = "success"
);
audit::record_best_effort(
audit,
"acl.swap",
&auth.did,
Some(&new_did),
"success",
Some(channel),
None,
)
.await;
Ok(to_result_body(&entry))
}
use vta_sdk::protocols::acl_management::entry::{AclEntry as WireAclEntry, to_epoch};
pub async fn grant_from_entry(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
entry: WireAclEntry,
channel: &str,
) -> Result<CreateAclResponseBody, AppError> {
let role = Role::parse(&entry.role)
.map_err(|_| AppError::Validation(format!("invalid role: {}", entry.role)))?;
let capabilities = match vta_sdk::protocols::acl_management::entry::capabilities_from_ext(
entry.ext.as_ref(),
) {
Ok(Some(names)) => parse_capability_names(&names)?,
Ok(None) => Vec::new(),
Err(reason) => return Err(AppError::Validation(reason)),
};
let handoff = vta_sdk::protocols::acl_management::entry::handoff_from_ext(entry.ext.as_ref())
.map_err(AppError::Validation)?;
let stored = create_acl(
acl_ks,
audit,
contexts_ks,
auth,
CreateAclParams {
did: entry.subject.clone(),
role,
label: entry.label.clone(),
allowed_contexts: entry.scopes.clone(),
expires_at: entry.expires_at.map(to_epoch),
step_up_approver: entry.step_up_approver(),
step_up_require: entry.step_up_require(),
approve_scope: entry.approve_scope(),
allowed_keys: entry
.allowed_keys
.clone()
.map(|keys| keys.into_iter().collect()),
capabilities,
handoff,
},
channel,
)
.await?;
Ok(CreateAclResponseBody {
entry: WireAclEntry::from_result(&stored),
})
}
pub async fn show_by_subject(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
subject: &str,
channel: &str,
) -> Result<GetAclResultBody, AppError> {
let stored = get_acl(acl_ks, auth, subject, channel).await?;
Ok(GetAclResultBody {
entry: WireAclEntry::from_result(&stored),
redacted_fields: Vec::new(),
})
}
pub async fn list_entries(
acl_ks: &KeyspaceHandle,
auth: &AuthClaims,
scope: Option<&str>,
direction: ContextDirection,
channel: &str,
) -> Result<ListAclResultBody, AppError> {
let stored = list_acl(acl_ks, auth, scope, direction, channel).await?;
Ok(ListAclResultBody {
entries: stored.iter().map(WireAclEntry::from_result).collect(),
truncated: false,
cursor: None,
redacted_fields: Vec::new(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn change_role_by_subject(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
subject: &str,
from_role: &str,
to_role: &str,
reason: Option<&str>,
channel: &str,
) -> Result<CreateAclResponseBody, AppError> {
let stored = change_role(
acl_ks, audit, auth, subject, from_role, to_role, reason, channel,
)
.await?;
Ok(CreateAclResponseBody {
entry: WireAclEntry::from_result(&stored),
})
}
pub async fn update_from_params(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
subject: &str,
params: UpdateAclParams,
channel: &str,
) -> Result<CreateAclResponseBody, AppError> {
let stored = update_acl(acl_ks, audit, contexts_ks, auth, subject, params, channel).await?;
Ok(CreateAclResponseBody {
entry: WireAclEntry::from_result(&stored),
})
}
pub async fn revoke_by_subject(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
subject: &str,
scopes: Option<Vec<String>>,
channel: &str,
) -> Result<DeleteAclResultBody, AppError> {
if let Some(scopes) = scopes.filter(|s| !s.is_empty()) {
return Err(AppError::Validation(format!(
"scope reduction is not supported by this maintainer — `scopes` named {} entr{}, \
but only full revocation is implemented. Omit `scopes` to remove the entry.",
scopes.len(),
if scopes.len() == 1 { "y" } else { "ies" }
)));
}
let stored = get_acl(acl_ks, auth, subject, channel).await?;
delete_acl(acl_ks, audit, auth, subject, channel).await?;
Ok(DeleteAclResultBody {
entry: WireAclEntry::from_result(&stored),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AclEntry, entry_has_capability, store_acl_entry};
use crate::store::Store;
use vti_common::config::StoreConfig;
async fn fresh_store() -> (
Store,
KeyspaceHandle,
vta_audit::SharedAuditSink,
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: vta_audit::SharedAuditSink =
vta_audit::shared_keyspace_sink(store.keyspace(crate::keyspaces::AUDIT).unwrap());
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
(store, acl_ks, audit, 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,
context_policy: None,
},
)
.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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn super_admin(did: &str) -> AuthClaims {
ctx_admin(did, &[])
}
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();
}
#[tokio::test]
async fn may_manage_subject_follows_acl_management() {
let (_store, acl_ks, _audit, _contexts_ks, _dir) = fresh_store().await;
seed_target(&acl_ks, "did:key:zInA", &["ctx-a"]).await;
seed_target(&acl_ks, "did:key:zInB", &["ctx-b"]).await;
seed_target(&acl_ks, "did:key:zSuper", &[]).await;
let a_admin = ctx_admin("did:key:zAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &a_admin).await;
let may = async |auth: &AuthClaims, subject: &str| {
may_manage_subject(&acl_ks, auth, subject).await.unwrap()
};
assert!(may(&a_admin, "did:key:zAdminA").await, "own sessions");
assert!(
may(&a_admin, "did:key:zInA").await,
"a subject in its context"
);
assert!(
!may(&a_admin, "did:key:zInB").await,
"another context's subject"
);
assert!(!may(&a_admin, "did:key:zSuper").await, "a super-admin");
assert!(
!may(&a_admin, "did:key:zNoEntry").await,
"a subject with no entry"
);
let sup = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &sup).await;
for s in [
"did:key:zInA",
"did:key:zInB",
"did:key:zSuper",
"did:key:zNoEntry",
] {
assert!(may(&sup, s).await, "super-admin reaches {s}");
}
let reader = AuthClaims {
role: Role::Reader,
..ctx_admin("did:key:zReader", &["ctx-a"])
};
seed_caller(&acl_ks, &reader).await;
assert!(may(&reader, "did:key:zReader").await);
assert!(!may(&reader, "did:key:zInA").await);
}
async fn seed_foreign_admin_conferring_into(
acl_ks: &KeyspaceHandle,
did: &str,
admins: &[&str],
confers: &[&str],
) {
store_acl_entry(
acl_ks,
&AclEntry::new(did, Role::Admin, "seed")
.with_contexts(admins.iter().map(|s| s.to_string()).collect())
.with_approve_scope(ApproveScope::Contexts(
confers.iter().map(|s| s.to_string()).collect(),
)),
)
.await
.unwrap();
}
#[tokio::test]
async fn get_acl_surfaces_an_approver_conferring_into_callers_context() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zApproverRead";
store_acl_entry(
&acl_ks,
&AclEntry::new(target, Role::Reader, "seed")
.with_approve_scope(ApproveScope::Contexts(vec!["ctx-a".into()])),
)
.await
.unwrap();
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let body = get_acl(&acl_ks, &caller, target, "test")
.await
.expect("an approver in my context must be auditable");
assert_eq!(body.did, target);
let _ = (audit, contexts_ks);
}
#[tokio::test]
async fn list_acl_includes_an_approver_conferring_into_callers_context() {
let (_store, acl_ks, _audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
store_acl_entry(
&acl_ks,
&AclEntry::new("did:key:zApproverList", Role::Reader, "seed")
.with_approve_scope(ApproveScope::All),
)
.await
.unwrap();
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let body = list_acl(&acl_ks, &caller, None, ContextDirection::default(), "test")
.await
.unwrap();
assert!(
body.iter().any(|e| e.did == "did:key:zApproverList"),
"an approve-all holder must be auditable by every context admin"
);
}
async fn seed_tenant_subtree(acl_ks: &KeyspaceHandle) {
seed_target(acl_ks, "did:key:zUnitAdmin", &["acme/eng"]).await;
seed_target(acl_ks, "did:key:zTenantAdmin", &["acme"]).await;
seed_target(acl_ks, "did:key:zGateway", &["acme/eng/attestation"]).await;
seed_target(acl_ks, "did:key:zSigner", &["acme/eng/signing"]).await;
seed_target(acl_ks, "did:key:zOps", &["acme/ops/keys"]).await;
}
async fn dids_for(
acl_ks: &KeyspaceHandle,
caller: &AuthClaims,
ctx: &str,
direction: ContextDirection,
) -> Vec<String> {
let body = list_acl(acl_ks, caller, Some(ctx), direction, "test")
.await
.unwrap();
let mut dids: Vec<String> = body.into_iter().map(|e| e.did).collect();
dids.sort();
dids
}
#[tokio::test]
async fn a_subtree_listing_returns_the_leaves_an_act_in_listing_omits() {
let (_store, acl_ks, _audit, _contexts_ks, _dir) = fresh_store().await;
seed_tenant_subtree(&acl_ks).await;
let caller = super_admin("did:key:zRoot");
assert_eq!(
dids_for(&acl_ks, &caller, "acme/eng", ContextDirection::ActingIn).await,
["did:key:zTenantAdmin", "did:key:zUnitAdmin"],
"act-in: authority over the unit, which is not what a sweep revokes"
);
assert_eq!(
dids_for(&acl_ks, &caller, "acme/eng", ContextDirection::Subtree).await,
["did:key:zGateway", "did:key:zSigner", "did:key:zUnitAdmin"],
"subtree: every grant inside the unit, and nothing from acme/ops"
);
assert_eq!(
dids_for(&acl_ks, &caller, "acme/eng", ContextDirection::Any).await,
[
"did:key:zGateway",
"did:key:zSigner",
"did:key:zTenantAdmin",
"did:key:zUnitAdmin"
],
"any: the union — the auditor's question"
);
}
#[tokio::test]
async fn the_default_direction_is_the_historical_filter() {
let (_store, acl_ks, _audit, _contexts_ks, _dir) = fresh_store().await;
seed_tenant_subtree(&acl_ks).await;
let caller = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &caller).await;
assert_eq!(
dids_for(&acl_ks, &caller, "acme/eng", ContextDirection::default()).await,
dids_for(&acl_ks, &caller, "acme/eng", ContextDirection::ActingIn).await,
);
}
#[tokio::test]
async fn a_subtree_listing_does_not_widen_what_a_context_admin_can_see() {
let (_store, acl_ks, _audit, _contexts_ks, _dir) = fresh_store().await;
seed_tenant_subtree(&acl_ks).await;
let caller = ctx_admin("did:key:zOpsAdmin", &["acme/ops"]);
seed_caller(&acl_ks, &caller).await;
for direction in ContextDirection::ALL {
let dids = dids_for(&acl_ks, &caller, "acme/eng", direction).await;
assert!(
dids.is_empty(),
"{direction} leaked acme/eng entries to an acme/ops admin: {dids:?}"
);
}
}
#[tokio::test]
async fn a_direction_without_a_context_is_refused() {
let (_store, acl_ks, _audit, _contexts_ks, _dir) = fresh_store().await;
seed_tenant_subtree(&acl_ks).await;
let caller = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &caller).await;
let err = list_acl(&acl_ks, &caller, None, ContextDirection::Subtree, "test")
.await
.expect_err("a direction needs a context");
assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
list_acl(&acl_ks, &caller, None, ContextDirection::default(), "test")
.await
.expect("the default direction with no context is just an unfiltered list");
}
#[tokio::test]
async fn delete_acl_refuses_an_entry_that_acts_outside_callers_contexts() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zForeignAdmin";
seed_foreign_admin_conferring_into(&acl_ks, target, &["ctx-b"], &["ctx-a"]).await;
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
get_acl(&acl_ks, &caller, target, "test")
.await
.expect("confers into ctx-a, so ctx-a's admin may read it");
let err = delete_acl(&acl_ks, &audit, &caller, target, "test")
.await
.expect_err("ctx-a admin must not delete an entry that administers ctx-b");
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden (the row is already readable), got {err:?}"
);
}
#[tokio::test]
async fn delete_acl_still_conflates_a_wholly_invisible_entry_to_not_found() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zInvisible";
seed_target(&acl_ks, target, &["ctx-b"]).await;
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let err = delete_acl(&acl_ks, &audit, &caller, target, "test")
.await
.expect_err("not visible, not auditable");
assert!(
matches!(err, AppError::NotFound(_)),
"expected NotFound so existence is not disclosed, got {err:?}"
);
}
#[test]
fn parse_step_up_require_refuses_every_override() {
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);
for v in ["self", "delegated", "delegated-any", "none", "nope"] {
let err = parse_step_up_require(Some(v))
.expect_err("an override must be refused, never silently dropped");
let msg = err.to_string();
assert!(
msg.contains("pnm approvals require"),
"the refusal must name what replaces it, got: {msg}"
);
}
}
#[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()]);
}
#[test]
fn to_result_body_echoes_approve_scope() {
let base = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC");
let scoped = base
.clone()
.with_approve_scope(ApproveScope::Contexts(vec!["openvtc".into()]));
let body = to_result_body(&scoped);
assert!(!body.approve_all_contexts);
assert_eq!(body.approve_contexts, vec!["openvtc"]);
let all = base.clone().with_approve_scope(ApproveScope::All);
let body = to_result_body(&all);
assert!(body.approve_all_contexts);
assert!(body.approve_contexts.is_empty());
let body = to_result_body(&base);
assert!(!body.approve_all_contexts);
assert!(body.approve_contexts.is_empty());
}
#[test]
fn acl_entry_can_confer_matches_scope_and_admin_but_not_a_sibling_context() {
let scoped = AclEntry::new("did:key:zApprover", Role::Reader, "seed")
.with_approve_scope(ApproveScope::Contexts(vec!["openvtc".into()]));
assert!(acl_entry_can_confer(&scoped, "openvtc"));
assert!(
!acl_entry_can_confer(&scoped, "openvtc-glenn"),
"approve-scope `openvtc` must NOT confer the distinct context `openvtc-glenn`"
);
let all = AclEntry::new("did:key:zAll", Role::Reader, "seed")
.with_approve_scope(ApproveScope::All);
assert!(acl_entry_can_confer(&all, "openvtc-glenn"));
let ctx_admin = AclEntry::new("did:key:zAdmin", Role::Admin, "seed")
.with_contexts(vec!["openvtc-glenn".into()]);
assert!(acl_entry_can_confer(&ctx_admin, "openvtc-glenn"));
assert!(!acl_entry_can_confer(&ctx_admin, "some-other-context"));
let super_admin = AclEntry::new("did:key:zSuper", Role::Admin, "seed");
assert!(acl_entry_can_confer(&super_admin, "openvtc-glenn"));
let reader = AclEntry::new("did:key:zReader", Role::Reader, "seed");
assert!(!acl_entry_can_confer(&reader, "openvtc-glenn"));
}
#[tokio::test]
async fn a_grant_creates_the_entry_already_narrowed() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let mut wire = WireAclEntry::new(
"did:key:zNew".into(),
"application".into(),
vec!["ctx-a".into()],
);
wire.ext = Some(serde_json::json!({
"org.openvtc.capabilities": ["memory-read", "room-present"],
}));
let body = grant_from_entry(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, super_admin("did:key:zRoot")).await,
wire,
"test",
)
.await
.expect("an application entry may be created narrowed");
let echoed = vta_sdk::protocols::acl_management::entry::capabilities_from_ext(
body.entry.ext.as_ref(),
)
.expect("the echoed ext parses");
assert_eq!(
echoed,
Some(vec!["memory-read".to_string(), "room-present".to_string()])
);
let stored = get_acl_entry(&acl_ks, "did:key:zNew")
.await
.unwrap()
.unwrap();
assert!(entry_has_capability(&stored, Capability::MemoryRead));
assert!(
!entry_has_capability(&stored, Capability::MemoryWrite),
"the capability it did not name must be gone from the moment it existed"
);
}
#[tokio::test]
async fn a_grant_refuses_a_capability_the_role_lacks() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let mut wire =
WireAclEntry::new("did:key:zNew".into(), "reader".into(), vec!["ctx-a".into()]);
wire.ext = Some(serde_json::json!({
"org.openvtc.capabilities": ["room-present"],
}));
let err = grant_from_entry(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, super_admin("did:key:zRoot")).await,
wire,
"test",
)
.await
.expect_err("a reader cannot be created holding room-present");
assert!(
matches!(err, AppError::Validation(ref m) if m.contains("RoomPresent")),
"the refusal must name what it refused: {err:?}"
);
assert!(
get_acl_entry(&acl_ks, "did:key:zNew")
.await
.unwrap()
.is_none(),
"a refused grant must not leave an entry behind"
);
}
#[tokio::test]
async fn a_grant_refuses_an_unknown_capability_name() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let mut wire = WireAclEntry::new(
"did:key:zNew".into(),
"application".into(),
vec!["ctx-a".into()],
);
wire.ext = Some(serde_json::json!({
"org.openvtc.capabilities": ["memory-read", "teleport"],
}));
let err = grant_from_entry(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, super_admin("did:key:zRoot")).await,
wire,
"test",
)
.await
.expect_err("an unknown capability name must be refused");
assert!(
matches!(err, AppError::Validation(ref m) if m.contains("teleport")),
"the refusal must name the word it did not recognise: {err:?}"
);
}
#[tokio::test]
async fn update_acl_narrows_and_clears_capabilities() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zAgent";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let admin = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &admin).await;
let set = |caps: Option<Vec<Capability>>| UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
capabilities: caps,
};
let narrowed = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(vec![Capability::MemoryRead])),
"test",
)
.await
.expect("narrowing within the role is allowed");
assert_eq!(narrowed.capabilities, vec!["memory-read".to_string()]);
let stored = get_acl_entry(&acl_ks, target).await.unwrap().unwrap();
assert!(entry_has_capability(&stored, Capability::MemoryRead));
assert!(
!entry_has_capability(&stored, Capability::MemoryWrite),
"an admin narrowed to memory-read must lose memory-write"
);
let untouched = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(None),
"test",
)
.await
.expect("an update that says nothing about capabilities");
assert_eq!(untouched.capabilities, vec!["memory-read".to_string()]);
let cleared = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(Vec::new())),
"test",
)
.await
.expect("clearing is allowed");
assert!(cleared.capabilities.is_empty());
let stored = get_acl_entry(&acl_ks, target).await.unwrap().unwrap();
assert!(entry_has_capability(&stored, Capability::MemoryWrite));
}
#[tokio::test]
async fn update_acl_refuses_a_capability_the_role_lacks() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zReader";
store_acl_entry(
&acl_ks,
&AclEntry::new(target, Role::Reader, "seed").with_contexts(vec!["ctx-a".into()]),
)
.await
.unwrap();
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, super_admin("did:key:zRoot")).await,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
capabilities: Some(vec![Capability::RoomPresent]),
},
"test",
)
.await
.expect_err("a reader cannot be narrowed to a capability it never had");
assert!(
matches!(err, AppError::Validation(ref m) if m.contains("RoomPresent")),
"the refusal must name what it refused: {err:?}"
);
let stored = get_acl_entry(&acl_ks, target).await.unwrap().unwrap();
assert!(
stored.capabilities.is_empty(),
"a refused update must store nothing"
);
}
#[tokio::test]
async fn update_acl_sets_and_revokes_approve_scope() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zApprover";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let admin = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &admin).await;
let set = |scope| UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: Some(scope),
expires_at: None,
reason: None,
capabilities: None,
};
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(ApproveScope::All),
"test",
)
.await
.expect("super admin may grant approve-all");
assert_eq!(
get_acl_entry(&acl_ks, target)
.await
.unwrap()
.unwrap()
.approve_scope,
ApproveScope::All
);
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(ApproveScope::Contexts(vec!["ctx-b".into()])),
"test",
)
.await
.expect("narrowing to a scoped grant");
assert_eq!(
get_acl_entry(&acl_ks, target)
.await
.unwrap()
.unwrap()
.approve_scope,
ApproveScope::Contexts(vec!["ctx-b".into()])
);
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(ApproveScope::None),
"test",
)
.await
.expect("revoking confers nothing");
assert_eq!(
get_acl_entry(&acl_ks, target)
.await
.unwrap()
.unwrap()
.approve_scope,
ApproveScope::None
);
}
#[tokio::test]
async fn update_acl_sets_empties_and_clears_the_key_filter() {
use std::collections::BTreeSet;
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zSigner";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let admin = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &admin).await;
let set = |replacement| UpdateAclParams {
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
allowed_keys: replacement,
capabilities: None,
};
let stored_filter = |acl_ks: &KeyspaceHandle| {
let acl_ks = acl_ks.clone();
async move {
get_acl_entry(&acl_ks, target)
.await
.unwrap()
.unwrap()
.allowed_keys
}
};
let keys: BTreeSet<String> = ["key-1".to_string()].into_iter().collect();
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(Some(keys.clone()))),
"test",
)
.await
.expect("setting the filter");
assert_eq!(stored_filter(&acl_ks).await, Some(keys.clone()));
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
UpdateAclParams {
label: Some("relabelled".into()),
..set(None)
},
"test",
)
.await
.unwrap();
assert_eq!(
stored_filter(&acl_ks).await,
Some(keys),
"omitting allowedKeys must leave the filter unchanged"
);
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(Some(BTreeSet::new()))),
"test",
)
.await
.expect("narrowing to no keys");
assert_eq!(stored_filter(&acl_ks).await, Some(BTreeSet::new()));
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(None)),
"test",
)
.await
.expect("clearing the filter");
assert_eq!(stored_filter(&acl_ks).await, None);
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
set(Some(Some(["".to_string()].into_iter().collect()))),
"test",
)
.await
.expect_err("an empty key id must be refused");
assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
}
#[tokio::test]
async fn update_acl_leaves_approve_scope_alone_when_absent() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let target = "did:key:zApprover";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let admin = super_admin("did:key:zRoot");
seed_caller(&acl_ks, &admin).await;
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: Some(ApproveScope::All),
expires_at: None,
reason: None,
capabilities: None,
},
"test",
)
.await
.unwrap();
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&admin,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: Some("renamed".into()),
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
},
"test",
)
.await
.unwrap();
let entry = get_acl_entry(&acl_ks, target).await.unwrap().unwrap();
assert_eq!(entry.label.as_deref(), Some("renamed"));
assert_eq!(
entry.approve_scope,
ApproveScope::All,
"scope must survive an unrelated edit"
);
}
#[tokio::test]
async fn update_acl_applies_the_same_approve_grant_check_as_create() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zApprover";
seed_target(&acl_ks, target, &["ctx-a"]).await;
let ctx_admin_a = ctx_admin("did:key:zCallerA", &["ctx-a"]);
seed_caller(&acl_ks, &ctx_admin_a).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&ctx_admin_a,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: Some(ApproveScope::All),
expires_at: None,
reason: None,
capabilities: None,
},
"test",
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"approve-all is super-admin only: {err:?}"
);
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&ctx_admin_a,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: Some(ApproveScope::Contexts(vec!["ctx-b".into()])),
expires_at: None,
reason: None,
capabilities: None,
},
"test",
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"cannot confer a context it lacks: {err:?}"
);
}
#[tokio::test]
async fn update_acl_rejects_shrink_across_caller_scope() {
let (_store, acl_ks, audit, 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"]);
seed_caller(&acl_ks, &caller).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into()]),
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
},
"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, 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"]);
seed_caller(&acl_ks, &caller).await;
let body = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into()]),
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
},
"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, 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"]);
seed_caller(&acl_ks, &caller).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into(), "ctx-b".into()]),
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
},
"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, 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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
seed_caller(&acl_ks, &caller).await;
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
CreateAclParams {
did: "did:key:zNewAdmin".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-typo".into()],
..Default::default()
},
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn a_scoped_admin_cannot_grant_holder_authority() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-work"]).await;
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, ctx_admin("did:key:zScoped", &["ctx-work"])).await,
CreateAclParams {
did: "did:key:zPuppet".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-work".into()],
capabilities: vec![Capability::PersonaHolder],
..Default::default()
},
"test",
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"a scoped admin must not confer holder authority, got {err:?}"
);
}
#[tokio::test]
async fn a_super_admin_grants_holder_authority_without_narrowing() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-work"]).await;
create_acl(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, super_admin("did:key:zSuper")).await,
CreateAclParams {
did: "did:key:zClient".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-work".into()],
capabilities: vec![Capability::PersonaHolder],
..Default::default()
},
"test",
)
.await
.unwrap();
let stored = get_acl_entry(&acl_ks, "did:key:zClient")
.await
.unwrap()
.expect("entry stored");
assert!(entry_has_capability(&stored, Capability::PersonaHolder));
assert!(
entry_has_capability(&stored, Capability::VaultRead),
"an additive grant must not narrow the role it rides on"
);
assert_eq!(
stored.allowed_contexts,
vec!["ctx-work".to_string()],
"and it must not widen the entry's context scope either"
);
}
#[tokio::test]
async fn a_scoped_admin_cannot_grant_holder_authority_by_update() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-work"]).await;
seed_target(&acl_ks, "did:key:zPuppet", &["ctx-work"]).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&seeded(&acl_ks, ctx_admin("did:key:zScoped", &["ctx-work"])).await,
"did:key:zPuppet",
UpdateAclParams {
capabilities: Some(vec![Capability::PersonaHolder]),
allowed_keys: None,
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
},
"test",
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"the update path is the other way in, got {err:?}"
);
}
#[tokio::test]
async fn create_acl_accepts_known_context() {
let (_store, acl_ks, audit, 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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
seed_caller(&acl_ks, &caller).await;
let body = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
CreateAclParams {
did: "did:key:zNewAdmin".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-real".into()],
..Default::default()
},
"test",
)
.await
.unwrap();
assert_eq!(body.allowed_contexts, vec!["ctx-real".to_string()]);
}
#[tokio::test]
async fn context_admin_can_mint_a_least_privilege_approver() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let body = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
CreateAclParams {
did: "did:key:zApprover".into(),
role: Role::Reader,
allowed_contexts: Vec::new(), approve_scope: ApproveScope::Contexts(vec!["ctx-a".into()]),
..Default::default()
},
"test",
)
.await
.expect("a context admin may create a least-privilege approver for its own context");
assert!(
body.allowed_contexts.is_empty(),
"the approver acts nowhere"
);
assert!(body.approve_contexts.contains(&"ctx-a".to_string()));
}
#[tokio::test]
async fn context_admin_cannot_confer_a_foreign_context_via_the_approver() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
CreateAclParams {
did: "did:key:zApprover".into(),
role: Role::Reader,
allowed_contexts: Vec::new(),
approve_scope: ApproveScope::Contexts(vec!["ctx-b".into()]),
..Default::default()
},
"test",
)
.await
.expect_err("conferring ctx-b requires administering it");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn context_admin_still_cannot_mint_a_super_admin() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let caller = ctx_admin("did:key:zCtxAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
CreateAclParams {
did: "did:key:zWouldBeSuper".into(),
role: Role::Admin,
allowed_contexts: Vec::new(), ..Default::default()
},
"test",
)
.await
.expect_err("a context admin must not mint a super-admin");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn delete_acl_rejects_initiator_deleting_admin() {
let (_store, acl_ks, audit, 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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
seed_caller(&acl_ks, &caller).await;
let err = delete_acl(&acl_ks, &audit, &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, 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"]);
seed_caller(&acl_ks, &caller).await;
delete_acl(&acl_ks, &audit, &caller, admin_target, "test")
.await
.expect("admin-on-admin delete succeeds");
assert!(
get_acl_entry(&acl_ks, admin_target)
.await
.unwrap()
.is_none(),
"the entry is gone after a delete"
);
}
#[tokio::test]
async fn update_acl_rejects_adding_unknown_context() {
let (_store, acl_ks, audit, 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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
seed_caller(&acl_ks, &caller).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
target,
UpdateAclParams {
allowed_keys: None,
role: None,
label: None,
step_up_approver: None,
step_up_require: None,
allowed_contexts: Some(vec!["ctx-a".into(), "ctx-ghost".into()]),
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
},
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
async fn seed_caller(acl_ks: &KeyspaceHandle, auth: &AuthClaims) {
store_acl_entry(
acl_ks,
&AclEntry::new(auth.did.clone(), auth.role.clone(), "seed")
.with_contexts(auth.allowed_contexts.clone()),
)
.await
.unwrap();
}
async fn seeded(acl_ks: &KeyspaceHandle, auth: AuthClaims) -> AuthClaims {
seed_caller(acl_ks, &auth).await;
auth
}
fn no_change() -> UpdateAclParams {
UpdateAclParams {
role: None,
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
expires_at: None,
reason: None,
capabilities: None,
allowed_keys: None,
}
}
fn keys(ids: &[&str]) -> std::collections::BTreeSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
const BRIDGE: &str = "did:key:zGitBridge";
async fn seed_narrowed_bridge(acl_ks: &KeyspaceHandle) -> (AuthClaims, u64) {
let expires = now_epoch() + 3600;
store_acl_entry(
acl_ks,
&AclEntry::new(BRIDGE, Role::Admin, "seed")
.with_contexts(vec!["vgi-bridge".into()])
.with_capabilities(vec![Capability::Sign])
.with_allowed_keys(Some(keys(&["k-bridge"])))
.with_expires_at(Some(expires)),
)
.await
.unwrap();
(ctx_admin(BRIDGE, &["vgi-bridge"]), expires)
}
#[tokio::test]
async fn vti_acl_052_a_context_admin_cannot_update_its_own_entry() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["vgi-bridge", "vgi-bridge/sub", "other"]).await;
let (bridge, expires) = seed_narrowed_bridge(&acl_ks).await;
let before = get_acl_entry(&acl_ks, BRIDGE).await.unwrap().unwrap();
let attempts: Vec<(&str, UpdateAclParams)> = vec![
(
"clear the capability narrowing",
UpdateAclParams {
capabilities: Some(Vec::new()),
..no_change()
},
),
(
"drop the key filter",
UpdateAclParams {
allowed_keys: Some(None),
..no_change()
},
),
(
"extend the expiry",
UpdateAclParams {
expires_at: Some(expires + 365 * 24 * 3600),
..no_change()
},
),
(
"add a foreign context",
UpdateAclParams {
allowed_contexts: Some(vec!["vgi-bridge".into(), "other".into()]),
..no_change()
},
),
(
"add a sub-context",
UpdateAclParams {
allowed_contexts: Some(vec!["vgi-bridge".into(), "vgi-bridge/sub".into()]),
..no_change()
},
),
(
"grant itself approve authority",
UpdateAclParams {
approve_scope: Some(ApproveScope::Contexts(vec!["vgi-bridge".into()])),
..no_change()
},
),
(
"relabel",
UpdateAclParams {
label: Some("mine".into()),
..no_change()
},
),
];
for (what, params) in attempts {
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&bridge,
BRIDGE,
params,
"test",
)
.await
.expect_err(what);
assert!(
matches!(err, AppError::Forbidden(_)),
"{what}: expected Forbidden, got {err:?}"
);
}
let after = get_acl_entry(&acl_ks, BRIDGE).await.unwrap().unwrap();
assert_eq!(after.capabilities, before.capabilities);
assert_eq!(after.allowed_keys, before.allowed_keys);
assert_eq!(after.expires_at, before.expires_at);
assert_eq!(after.allowed_contexts, before.allowed_contexts);
assert_eq!(after.label, before.label);
}
#[tokio::test]
async fn vti_acl_052_a_subject_cannot_change_its_own_role() {
let (_store, acl_ks, audit, _contexts_ks, _dir) = fresh_store().await;
let (bridge, _) = seed_narrowed_bridge(&acl_ks).await;
let err = change_role(
&acl_ks,
&audit,
&bridge,
BRIDGE,
"admin",
"initiator",
None,
"test",
)
.await
.expect_err("self role change");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let stored = get_acl_entry(&acl_ks, BRIDGE).await.unwrap().unwrap();
assert_eq!(stored.role, Role::Admin);
}
#[tokio::test]
async fn vti_acl_053_a_narrowed_admin_cannot_mint_a_wider_sibling() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["vgi-bridge"]).await;
let (bridge, expires) = seed_narrowed_bridge(&acl_ks).await;
let sibling = |n: u8| format!("did:key:zSibling{n}");
let params = |n: u8| CreateAclParams {
did: sibling(n),
role: Role::Admin,
allowed_contexts: vec!["vgi-bridge".into()],
..Default::default()
};
let refusals = [
("every capability", params(1)),
(
"caps within, no key filter",
CreateAclParams {
capabilities: vec![Capability::Sign],
..params(2)
},
),
(
"keys outside the caller's",
CreateAclParams {
capabilities: vec![Capability::Sign],
allowed_keys: Some(keys(&["k-bridge", "k-other"])),
expires_at: Some(expires),
..params(3)
},
),
(
"permanent",
CreateAclParams {
capabilities: vec![Capability::Sign],
allowed_keys: Some(keys(&["k-bridge"])),
..params(4)
},
),
(
"outlives the caller",
CreateAclParams {
capabilities: vec![Capability::Sign],
allowed_keys: Some(keys(&["k-bridge"])),
expires_at: Some(expires + 1),
..params(5)
},
),
];
for (what, p) in refusals {
let did = p.did.clone();
let err = create_acl(&acl_ks, &audit, &contexts_ks, &bridge, p, "test")
.await
.expect_err(what);
assert!(
matches!(err, AppError::Forbidden(_)),
"{what}: expected Forbidden, got {err:?}"
);
assert!(get_acl_entry(&acl_ks, &did).await.unwrap().is_none());
}
create_acl(
&acl_ks,
&audit,
&contexts_ks,
&bridge,
CreateAclParams {
capabilities: vec![Capability::Sign],
allowed_keys: Some(keys(&["k-bridge"])),
expires_at: Some(expires),
..params(6)
},
"test",
)
.await
.expect("an entry inside the caller's own on every axis");
}
#[tokio::test]
async fn vti_acl_053_an_update_cannot_widen_another_entry_past_the_caller() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["vgi-bridge"]).await;
let (bridge, _) = seed_narrowed_bridge(&acl_ks).await;
let target = "did:key:zNarrowPeer";
store_acl_entry(
&acl_ks,
&AclEntry::new(target, Role::Admin, "seed")
.with_contexts(vec!["vgi-bridge".into()])
.with_capabilities(vec![Capability::Sign])
.with_allowed_keys(Some(keys(&["k-bridge"])))
.with_expires_at(Some(now_epoch() + 60)),
)
.await
.unwrap();
for (what, params) in [
(
"clear capabilities",
UpdateAclParams {
capabilities: Some(Vec::new()),
..no_change()
},
),
(
"clear key filter",
UpdateAclParams {
allowed_keys: Some(None),
..no_change()
},
),
(
"extend past the caller",
UpdateAclParams {
expires_at: Some(now_epoch() + 10 * 3600),
..no_change()
},
),
] {
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&bridge,
target,
params,
"test",
)
.await
.expect_err(what);
assert!(matches!(err, AppError::Forbidden(_)), "{what}: got {err:?}");
}
}
#[tokio::test]
async fn a_context_admin_cannot_manage_an_entry_reaching_outside_its_contexts() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a", "ctx-b"]).await;
let target = "did:key:zStraddler";
store_acl_entry(
&acl_ks,
&AclEntry::new(target, Role::Initiator, "seed")
.with_contexts(vec!["ctx-a".into(), "ctx-b".into()]),
)
.await
.unwrap();
let caller = ctx_admin("did:key:zAdminA", &["ctx-a"]);
seed_caller(&acl_ks, &caller).await;
let err = update_acl(
&acl_ks,
&audit,
&contexts_ks,
&caller,
target,
UpdateAclParams {
expires_at: Some(now_epoch() + 60),
..no_change()
},
"test",
)
.await
.expect_err("update of a straddling entry");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let err = change_role(
&acl_ks,
&audit,
&caller,
target,
"initiator",
"reader",
None,
"test",
)
.await
.expect_err("role change of a straddling entry");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let err = delete_acl(&acl_ks, &audit, &caller, target, "test")
.await
.expect_err("delete of a straddling entry");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
assert!(get_acl_entry(&acl_ks, target).await.unwrap().is_some());
}
#[tokio::test]
async fn vti_acl_042_an_initiator_cannot_confer_approve_authority_it_lacks() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let initiator = AuthClaims {
role: Role::Initiator,
..ctx_admin("did:key:zInitiator", &["ctx-a"])
};
seed_caller(&acl_ks, &initiator).await;
let approver = |did: &str| CreateAclParams {
did: did.into(),
role: Role::Reader,
approve_scope: ApproveScope::Contexts(vec!["ctx-a".into()]),
..Default::default()
};
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&initiator,
approver("did:key:zApprover1"),
"test",
)
.await
.expect_err("an initiator holds no approve authority to confer");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
store_acl_entry(
&acl_ks,
&AclEntry::new(initiator.did.clone(), Role::Initiator, "seed")
.with_contexts(vec!["ctx-a".into()])
.with_approve_scope(ApproveScope::Contexts(vec!["ctx-a".into()])),
)
.await
.unwrap();
create_acl(
&acl_ks,
&audit,
&contexts_ks,
&initiator,
approver("did:key:zApprover2"),
"test",
)
.await
.expect("conferring approve authority the caller holds");
}
#[tokio::test]
async fn a_caller_without_an_entry_cannot_grant() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-a"]).await;
let ghost = super_admin("did:key:zNoEntry");
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&ghost,
CreateAclParams {
did: "did:key:zAnyone".into(),
role: Role::Reader,
allowed_contexts: vec!["ctx-a".into()],
..Default::default()
},
"test",
)
.await
.expect_err("no entry, no grant");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn vti_clt_029_a_rotation_preserves_the_entry_exactly() {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
let (_store, acl_ks, audit, _contexts_ks, _dir) = fresh_store().await;
let (old_did, _) = crate::test_support::did_for_seed(0xC1);
let expires = now_epoch() + 3600;
let original = AclEntry::new(old_did.clone(), Role::Admin, "did:key:zEnroller")
.with_label(Some("bootstrap".into()))
.with_contexts(vec!["vgi-bridge".into()])
.with_capabilities(vec![Capability::Sign])
.with_allowed_keys(Some(keys(&["k-bridge"])))
.with_approve_scope(ApproveScope::Contexts(vec!["vgi-bridge".into()]))
.with_expires_at(Some(expires))
.with_handoff(Some(unrestricted_marker("did:key:zEnroller")));
store_acl_entry(&acl_ks, &original).await.unwrap();
let auth = ctx_admin(&old_did, &["vgi-bridge"]);
let vta_did = "did:key:zThisVta";
let new_sk = ed25519_dalek::SigningKey::from_bytes(&[0xC2; 32]);
let (new_did, _) = crate::test_support::did_for_seed(0xC2);
let pres = vta_sdk::protocols::acl_management::swap::build_swap_presentation(
&new_sk,
&new_did,
vta_did,
now_epoch(),
300,
None,
);
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
swap_acl(&acl_ks, &audit, &auth, &pres, &resolver, vta_did, "test")
.await
.expect("rotation");
assert!(get_acl_entry(&acl_ks, &old_did).await.unwrap().is_none());
let moved = get_acl_entry(&acl_ks, &new_did).await.unwrap().unwrap();
assert_eq!(moved.role, original.role);
assert_eq!(moved.allowed_contexts, original.allowed_contexts);
assert_eq!(moved.capabilities, original.capabilities);
assert_eq!(moved.allowed_keys, original.allowed_keys, "key filter kept");
assert_eq!(moved.approve_scope, original.approve_scope);
assert_eq!(moved.expires_at, Some(expires), "expiry kept");
assert_eq!(moved.label, original.label);
assert_eq!(moved.created_by, original.created_by);
assert_eq!(moved.created_at, original.created_at);
assert!(
moved.handoff.is_none(),
"a rotation must not carry the hand-off marker (VTI-ACL-054)"
);
}
const GRANTER: &str = "did:key:zHandoffGranter";
const EPHEMERAL: &str = "did:key:zHandoffEphemeral";
fn unrestricted_marker(granted_by: &str) -> vti_common::acl::HandOff {
vti_common::acl::HandOff {
granted_by: granted_by.into(),
granted_at: now_epoch(),
bound: vti_common::acl::HandOffBound {
role: Role::Admin,
allowed_contexts: Vec::new(),
capabilities: Vec::new(),
approve_scope: ApproveScope::None,
allowed_keys: None,
expires_at: None,
},
}
}
fn successor(did: &str, contexts: &[&str]) -> HandOffSuccessor {
HandOffSuccessor {
did: did.into(),
role: Role::Admin,
label: None,
allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
capabilities: Vec::new(),
}
}
async fn marked_ephemeral(
acl_ks: &KeyspaceHandle,
audit: &vta_audit::SharedAuditSink,
contexts_ks: &KeyspaceHandle,
) -> AuthClaims {
let granter = seeded(acl_ks, super_admin(GRANTER)).await;
create_acl(
acl_ks,
audit,
contexts_ks,
&granter,
CreateAclParams {
did: EPHEMERAL.into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-h".into()],
expires_at: Some(now_epoch() + 3600),
handoff: true,
..Default::default()
},
"test",
)
.await
.expect("the granter marks the ephemeral");
ctx_admin(EPHEMERAL, &["ctx-h"])
}
#[tokio::test]
async fn vti_acl_055_a_hand_off_succeeds_once() {
let (store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h"]).await;
let eph = marked_ephemeral(&acl_ks, &audit, &contexts_ks).await;
let marked = get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().unwrap();
assert_eq!(
marked.handoff.as_ref().map(|h| h.granted_by.as_str()),
Some(GRANTER)
);
let out = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zSuccessor1", &["ctx-h"]),
"test",
Some("ctx-h"),
)
.await
.expect("the one rollover");
assert!(!out.handoff, "the successor carries no marker");
let succ = get_acl_entry(&acl_ks, "did:key:zSuccessor1")
.await
.unwrap()
.unwrap();
assert_eq!(
succ.expires_at, None,
"the granter's expiry, not the ephemeral's"
);
assert_eq!(succ.allowed_contexts, vec!["ctx-h".to_string()]);
assert!(succ.handoff.is_none());
assert!(get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().is_none());
let rows = store.keyspace(crate::keyspaces::AUDIT).unwrap();
let rows = rows.prefix_iter_raw("log:").await.unwrap();
let outcomes: Vec<String> = rows
.iter()
.filter_map(|(_, v)| serde_json::from_slice::<serde_json::Value>(v).ok())
.filter(|e| e["action"] == "acl.handoff")
.map(|e| e["outcome"].as_str().unwrap_or_default().to_string())
.collect();
assert!(outcomes.contains(&"committing".to_string()), "{outcomes:?}");
assert!(outcomes.contains(&"success".to_string()), "{outcomes:?}");
let err = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zSuccessor2", &["ctx-h"]),
"test",
Some("ctx-h"),
)
.await
.expect_err("a second rollover");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
assert!(
get_acl_entry(&acl_ks, "did:key:zSuccessor2")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn vti_acl_055_a_hand_off_after_expiry_is_refused() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h"]).await;
let eph = marked_ephemeral(&acl_ks, &audit, &contexts_ks).await;
let mut marked = get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().unwrap();
marked.expires_at = Some(now_epoch() - 1);
store_acl_entry(&acl_ks, &marked).await.unwrap();
let err = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zLate", &["ctx-h"]),
"test",
None,
)
.await
.expect_err("expired");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
assert!(
get_acl_entry(&acl_ks, "did:key:zLate")
.await
.unwrap()
.is_none()
);
assert!(get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().is_some());
}
#[tokio::test]
async fn vti_acl_055_a_hand_off_cannot_widen_past_the_granter() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h", "ctx-x"]).await;
let granter_expires = now_epoch() + 7200;
store_acl_entry(
&acl_ks,
&AclEntry::new(GRANTER, Role::Admin, "seed")
.with_contexts(vec!["ctx-h".into()])
.with_capabilities(vec![Capability::Sign])
.with_expires_at(Some(granter_expires)),
)
.await
.unwrap();
let granter = ctx_admin(GRANTER, &["ctx-h"]);
create_acl(
&acl_ks,
&audit,
&contexts_ks,
&granter,
CreateAclParams {
did: EPHEMERAL.into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-h".into()],
capabilities: vec![Capability::Sign],
expires_at: Some(now_epoch() + 600),
handoff: true,
..Default::default()
},
"test",
)
.await
.expect("a marked grant inside the granter");
let eph = ctx_admin(EPHEMERAL, &["ctx-h"]);
let err = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zWide", &["ctx-h"]),
"test",
None,
)
.await
.expect_err("wider capabilities");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let mut marked = get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().unwrap();
marked.allowed_contexts = vec!["ctx-h".into(), "ctx-x".into()];
store_acl_entry(&acl_ks, &marked).await.unwrap();
let eph_wide = ctx_admin(EPHEMERAL, &["ctx-h", "ctx-x"]);
let err = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph_wide,
HandOffSuccessor {
capabilities: vec![Capability::Sign],
..successor("did:key:zWide2", &["ctx-h", "ctx-x"])
},
"test",
None,
)
.await
.expect_err("a context the granter did not hold");
assert!(
matches!(&err, AppError::Forbidden(m) if m.contains(GRANTER)),
"got {err:?}"
);
assert!(
get_acl_entry(&acl_ks, "did:key:zWide2")
.await
.unwrap()
.is_none()
);
let out = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph_wide,
HandOffSuccessor {
capabilities: vec![Capability::Sign],
..successor("did:key:zNarrow", &["ctx-h"])
},
"test",
None,
)
.await
.expect("within the granter");
assert_eq!(out.expires_at, Some(granter_expires));
}
#[tokio::test]
async fn vti_acl_054_the_holder_cannot_set_the_marker() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h"]).await;
let eph = marked_ephemeral(&acl_ks, &audit, &contexts_ks).await;
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&eph,
CreateAclParams {
did: "did:key:zSecondBootstrap".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-h".into()],
expires_at: Some(now_epoch() + 60),
handoff: true,
..Default::default()
},
"test",
)
.await
.expect_err("a marked holder cannot mint a marker");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let granter = super_admin(GRANTER);
let err = create_acl(
&acl_ks,
&audit,
&contexts_ks,
&granter,
CreateAclParams {
did: "did:key:zPermanentBootstrap".into(),
role: Role::Admin,
allowed_contexts: vec!["ctx-h".into()],
handoff: true,
..Default::default()
},
"test",
)
.await
.expect_err("no expiry");
assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
update_acl(
&acl_ks,
&audit,
&contexts_ks,
&granter,
EPHEMERAL,
UpdateAclParams {
label: Some("relabelled".into()),
..no_change()
},
"test",
)
.await
.expect("an administrator's update");
let marked = get_acl_entry(&acl_ks, EPHEMERAL).await.unwrap().unwrap();
assert_eq!(
marked.handoff.as_ref().map(|h| h.granted_by.as_str()),
Some(GRANTER)
);
}
#[tokio::test]
async fn vti_acl_056_a_concurrent_double_hand_off_yields_one_success() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h"]).await;
let eph = marked_ephemeral(&acl_ks, &audit, &contexts_ks).await;
let (a, b) = tokio::join!(
exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zRaceA", &["ctx-h"]),
"test",
None,
),
exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&eph,
successor("did:key:zRaceB", &["ctx-h"]),
"test",
None,
),
);
assert_eq!(
[a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(),
1,
"a: {a:?}, b: {b:?}"
);
let landed = [
get_acl_entry(&acl_ks, "did:key:zRaceA").await.unwrap(),
get_acl_entry(&acl_ks, "did:key:zRaceB").await.unwrap(),
];
assert_eq!(landed.iter().filter(|e| e.is_some()).count(), 1);
}
#[tokio::test]
async fn vti_acl_058_no_marker_no_hand_off() {
let (_store, acl_ks, audit, contexts_ks, _dir) = fresh_store().await;
seed_contexts(&contexts_ks, &["ctx-h"]).await;
store_acl_entry(
&acl_ks,
&AclEntry::new(EPHEMERAL, Role::Admin, GRANTER)
.with_contexts(vec!["ctx-h".into()])
.with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let err = exercise_handoff(
&acl_ks,
&audit,
&contexts_ks,
&ctx_admin(EPHEMERAL, &["ctx-h"]),
successor("did:key:zUnmarked", &["ctx-h"]),
"test",
None,
)
.await
.expect_err("no marker");
assert!(
matches!(&err, AppError::Forbidden(m) if m.contains("VTI-ACL-058")),
"got {err:?}"
);
}
}