use affinidi_status_list::StatusPurpose;
use serde_json::json;
use tracing::{info, warn};
use vti_common::audit::{AuditEvent, MemberRemovedData, StatusListFlippedData};
use vti_common::error::AppError;
use super::execute::{self, EffectOutcome};
use super::{
Evidence, FactsInputs, Purpose, Verdict, VerifiedFacts, assemble_facts, decide,
effects::EffectPlan, load_actor_role, member_state,
};
use crate::acl::{VtcRole, get_acl_entry};
use crate::error::TaskError;
use crate::members::{Disposition, Member, get_member};
use crate::policy::{PolicyPurpose, load_active_compiled};
use crate::server::AppState;
#[derive(Debug)]
pub struct RoleChangeResult {
pub previous_role: String,
pub new_role: String,
}
static PROMOTE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn role_change_via_pipeline(
state: &AppState,
actor: &vti_common::auth::extractor::AuthClaims,
subject_did: &str,
current_role: &str,
target_role: &str,
op: Option<crate::acl::admin_consent::Operation<'_>>,
) -> Result<RoleChangeResult, AppError> {
match run_role_change(
state,
actor,
subject_did,
current_role,
target_role,
StepUpSource::Session { op },
)
.await?
{
RoleChangeOutcome::Changed(result) => Ok(result),
RoleChangeOutcome::StepUpRequired(_) => Err(AppError::Internal(
"a session-gated role change produced a bound step-up request".into(),
)),
}
}
#[derive(Debug, Clone, Copy)]
pub enum StepUpSource<'a> {
Session {
op: Option<crate::acl::admin_consent::Operation<'a>>,
},
BoundTo {
type_uri: &'a str,
payload: &'a serde_json::Value,
},
}
#[derive(Debug)]
pub enum RoleChangeOutcome {
Changed(RoleChangeResult),
StepUpRequired(Box<crate::acl::bound_step_up::ApproveRequest>),
}
pub async fn role_change_via_bound_step_up(
state: &AppState,
actor: &vti_common::auth::extractor::AuthClaims,
subject_did: &str,
current_role: &str,
target_role: &str,
type_uri: &str,
payload: &serde_json::Value,
) -> Result<RoleChangeOutcome, AppError> {
run_role_change(
state,
actor,
subject_did,
current_role,
target_role,
StepUpSource::BoundTo { type_uri, payload },
)
.await
}
async fn run_role_change(
state: &AppState,
actor: &vti_common::auth::extractor::AuthClaims,
subject_did: &str,
current_role: &str,
target_role: &str,
source: StepUpSource<'_>,
) -> Result<RoleChangeOutcome, AppError> {
let actor_did = actor.did.as_str();
let promoting = target_role == super::invariant::ADMIN_ROLE;
let _guard = if promoting {
Some(PROMOTE_LOCK.lock().await)
} else {
None
};
let live = get_acl_entry(&state.acl_ks, subject_did).await?;
if let Some(live) = live.as_ref()
&& live.role.to_string() != current_role
{
return Err(AppError::Conflict(format!(
"state mismatch: {subject_did} currently holds role {}, not {current_role}",
live.role
)));
}
let now = crate::auth::session::now_epoch();
let target: VtcRole = target_role
.parse()
.map_err(|_| AppError::Validation(format!("unknown role {target_role}")))?;
let next = live.as_ref().map(|prior| {
let mut next = prior.clone();
next.role = target.clone();
if crate::routes::acl::expressible_in_v0_1(prior) {
next.admin = target.implied_authority();
}
next
});
let authority_moves =
matches!((live.as_ref(), next.as_ref()), (Some(p), Some(n)) if p.admin != n.admin);
if authority_moves && actor_did == subject_did {
return Err(AppError::Forbidden(
"you cannot promote yourself or move your own administrative authority \
(VTI-ACL-052) — another administrator must run acl/change-role for you. You may \
change your own entry's label"
.into(),
));
}
if authority_moves && let (Some(prior), Some(next)) = (live.as_ref(), next.as_ref()) {
let actor_entry = get_acl_entry(&state.acl_ks, actor_did)
.await?
.ok_or_else(|| AppError::Forbidden(format!("{actor_did} has no ACL entry")))?;
if !crate::acl::granting::covers_entry(&actor_entry, prior, now) {
return Err(AppError::Forbidden(format!(
"{subject_did} holds authority outside yours — only an administrator holding \
vtc.roles.assign over everything it holds can move its role (VTI-ACL-050)"
)));
}
if let Err(refusal) = crate::acl::granting::check_write(&actor_entry, next, now) {
return Err(AppError::Forbidden(refusal.to_string()));
}
}
let demoted = match (live.as_ref(), next.as_ref()) {
(Some(p), Some(n)) if p.is_administrator() && n.admin.narrows_from(&p.admin) => {
Some(p.clone())
}
_ => None,
};
let conferred = match next.as_ref() {
Some(n) => crate::acl::admin_consent::newly_conferred(live.as_ref(), n, now),
None => vec![],
};
let unrestricted = !conferred.is_empty();
let widens = matches!(
(live.as_ref(), next.as_ref()),
(Some(p), Some(n)) if n.admin.widens_from(&p.admin)
);
let step_up = promoting
&& match source {
StepUpSource::Session { .. } => {
crate::acl::elevation::verified(actor, &state.sessions_ks).await
}
StepUpSource::BoundTo { .. } => true,
};
let facts = assemble_role_change_facts(
state,
actor_did,
subject_did,
current_role,
target_role,
step_up,
)
.await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::RoleChange,
)
.await?;
let allow = match decide(&verified, &policy)? {
Verdict::Allow(a) => a,
Verdict::Refer(r) => {
return Err(AppError::StepUpRequired(format!(
"role change deferred to the {} queue — complete the step-up ceremony",
r.queue
)));
}
Verdict::Deny(d) if d.code == super::Invariant::StepUpForAdmin.code() => {
return Err(crate::acl::elevation::required(&format!(
"promoting {subject_did} to admin"
)));
}
Verdict::Deny(d) if d.code == super::Invariant::SelfPromotion.code() => {
return Err(AppError::Forbidden(
"you cannot promote yourself; admin elevation requires a separate admin \
caller to run acl/change-role for you"
.into(),
));
}
Verdict::Deny(d) => {
return Err(AppError::Forbidden(format!(
"role change denied by policy ({})",
d.code
)));
}
Verdict::RequestMore(_) => {
return Err(AppError::Internal(
"role-change policy returned request_more; role change is synchronous".into(),
));
}
};
let granted = allow
.role
.ok_or_else(|| AppError::Internal("role-change allow carried no role".into()))?;
if unrestricted {
use crate::acl::admin_consent::{self, Operation, SignedGate};
let summary = format!(
"Move {subject_did} from {current_role} to {target_role}, giving it {}",
conferred
.iter()
.map(crate::acl::CapRef::display)
.collect::<Vec<_>>()
.join(", ")
);
let ready = match source {
StepUpSource::Session { op: Some(op) } => {
admin_consent::require(state, actor_did, subject_did, &conferred, op, &summary)
.await?
}
StepUpSource::Session { op: None } => {
return Err(AppError::Forbidden(format!(
"promoting {subject_did} to unrestricted admin needs another admin's consent, \
which only acl/change-role can carry"
)));
}
StepUpSource::BoundTo { type_uri, payload } => {
match admin_consent::gesture_then_consent(
state,
actor_did,
subject_did,
&conferred,
Operation { type_uri, payload },
&format!("Promote {subject_did} from {current_role} to {target_role}"),
&summary,
)
.await?
{
SignedGate::Ready(ready) => ready,
SignedGate::StepUpRequired(request) => {
return Ok(RoleChangeOutcome::StepUpRequired(request));
}
}
}
};
ready.spend(state).await?;
} else if widens {
match source {
StepUpSource::BoundTo { type_uri, payload } => {
let reason = format!("Move {subject_did} from {current_role} to {target_role}");
match crate::acl::bound_step_up::redeem_or_request(
state, actor_did, type_uri, payload, &reason,
)
.await?
{
crate::acl::bound_step_up::Gate::Satisfied => {}
crate::acl::bound_step_up::Gate::Required(request) => {
return Ok(RoleChangeOutcome::StepUpRequired(request));
}
}
}
StepUpSource::Session { .. } => {
if !promoting && !crate::acl::elevation::verified(actor, &state.sessions_ks).await {
return Err(crate::acl::elevation::required(&format!(
"moving {subject_did} to {target_role}"
)));
}
}
}
}
let mut demotion = None;
if let Some(prior) = demoted.as_ref() {
let StepUpSource::BoundTo { type_uri, payload } = source else {
return Err(AppError::Forbidden(format!(
"demoting administrator {subject_did} needs a passkey gesture bound to the \
operation (VTI-APV-019), which only acl/change-role can carry — send \
acl/change-role {{\"subject\": \"{subject_did}\", \"fromRole\": \"{current_role}\", \
\"toRole\": \"{target_role}\"}}"
)));
};
match crate::acl::admin_consent::settle_reduction(
state,
actor_did,
prior,
next.as_ref(),
crate::acl::admin_consent::Operation { type_uri, payload },
&format!("Demote administrator {subject_did} to {target_role}"),
&format!("Demote administrator {subject_did} to {target_role}"),
)
.await
{
Ok(Some(agreement)) => demotion = Some((prior.clone(), type_uri, agreement)),
Ok(None) => {}
Err(crate::error::TaskError::StepUp { request, .. }) => {
return Ok(RoleChangeOutcome::StepUpRequired(request));
}
Err(e) => return Err(e.into()),
}
}
let plan = EffectPlan::Remint {
subject: subject_did.to_string(),
role: granted.clone(),
};
let EffectOutcome::Reminted(outcome) = execute::apply(state, plan, actor_did).await? else {
return Err(AppError::Internal(
"remint effect did not produce an outcome".into(),
));
};
if let Some((prior, type_uri, agreement)) = demotion {
let after = crate::acl::get_acl_entry(&state.acl_ks, subject_did).await?;
crate::acl::admin_consent::after_reduction(
state,
actor_did,
&prior,
after.as_ref(),
agreement,
type_uri,
None,
true,
)
.await?;
}
if let Some(role_vac) = outcome.role_vac.as_ref()
&& let Err(e) =
crate::credentials::delivery::deliver_credentials(state, subject_did, &[role_vac]).await
{
warn!(
subject = %subject_did,
error = %e,
"role-VAC delivery failed on role change; the credential is issued and can be re-delivered"
);
}
Ok(RoleChangeOutcome::Changed(RoleChangeResult {
previous_role: outcome.previous_role.to_string(),
new_role: granted,
}))
}
async fn assemble_role_change_facts(
state: &AppState,
actor_did: &str,
subject_did: &str,
current_role: &str,
target_role: &str,
step_up: bool,
) -> Result<super::Facts, AppError> {
let subject_member = get_member(&state.members_ks, subject_did).await?;
assemble_facts(
state,
FactsInputs {
purpose: Purpose::RoleChange,
actor_did: actor_did.to_string(),
actor_role: load_actor_role(state, actor_did).await?,
subject_did: subject_did.to_string(),
subject_member: Some(member_state(
current_role.to_string(),
subject_member.as_ref(),
)),
evidence: Evidence {
vetting: None,
invitation: None,
presentation: None,
request: Some(json!({ "target_role": target_role, "step_up": step_up })),
criterion: None,
},
thread_id: None,
},
)
.await
}
#[derive(Debug)]
pub struct LeaveOutcome {
pub did: String,
pub disposition: String,
pub removed: bool,
}
use trust_tasks_rs::specs::vtc::members as members_specs;
pub const PURGE_ERR_NOT_FOUND: &str = members_specs::purge::v0_1::error_codes::NOT_FOUND.code;
pub const PURGE_ERR_LAST_ADMINISTRATOR: &str =
members_specs::purge::v0_1::error_codes::LAST_ADMINISTRATOR.code;
pub const ADMIN_REMOVE_ERR_NOT_FOUND: &str =
members_specs::admin_remove::v0_1::error_codes::NOT_FOUND.code;
pub const SELF_REMOVE_ERR_NOT_MEMBER: &str =
members_specs::self_remove::v0_1::error_codes::NOT_MEMBER.code;
pub async fn purge_member(
state: &AppState,
actor_did: &str,
target_did: &str,
) -> Result<LeaveOutcome, TaskError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let has_member = get_member(&state.members_ks, target_did).await?.is_some();
let prior_acl = get_acl_entry(&state.acl_ks, target_did).await?;
let has_acl = prior_acl.is_some();
if !has_member && !has_acl {
return Err(TaskError::declared(
PURGE_ERR_NOT_FOUND,
AppError::NotFound(format!("no member or tombstone to purge: {target_did}")),
));
}
let EffectOutcome::Departed(outcome) = execute::apply(
state,
EffectPlan::Depart {
subject: target_did.to_string(),
disposition: Some("purge".to_string()),
},
actor_did,
)
.await
.map_err(|e| match e {
e @ AppError::Conflict(_) => TaskError::declared(PURGE_ERR_LAST_ADMINISTRATOR, e),
e => TaskError::App(e),
})?
else {
return Err(
AppError::Internal("purge effect did not produce a departure outcome".into()).into(),
);
};
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::MemberRemoved(MemberRemovedData {
disposition: "purge".into(),
reason: "operator purge".into(),
prior_role: prior_acl.as_ref().map(|a| a.role.to_string()),
}),
)
.await?;
if let Some(slot) = outcome.revoked_slot {
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: StatusPurpose::Revocation.to_string(),
index: slot,
revoked: true,
}),
)
.await?;
}
for grant in &outcome.revoked_grants {
crate::vetting::vetters::audit_revoked_grant(audit_writer, actor_did, target_did, grant)
.await?;
}
info!(actor = actor_did, target = target_did, "member purged");
crate::ceremony::removal_notice::send(
state,
target_did,
vta_sdk::protocols::members::RemovalCode::Purged,
"purge",
None,
&chrono::Utc::now().to_rfc3339(),
actor_did,
)
.await;
Ok(LeaveOutcome {
did: target_did.to_string(),
disposition: "purge".into(),
removed: true,
})
}
pub async fn remove_inner(
state: &AppState,
actor_did: &str,
target_did: &str,
disposition: Option<Disposition>,
reason: String,
op: Option<crate::acl::admin_consent::Operation<'_>>,
) -> Result<LeaveOutcome, TaskError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let target_acl = get_acl_entry(&state.acl_ks, target_did).await?;
let target_member = get_member(&state.members_ks, target_did).await?;
if target_acl.is_none() && target_member.as_ref().is_none_or(Member::is_removed) {
let code = if actor_did == target_did {
SELF_REMOVE_ERR_NOT_MEMBER
} else {
ADMIN_REMOVE_ERR_NOT_FOUND
};
return Err(TaskError::declared(
code,
AppError::NotFound(format!("member not found: {target_did}")),
));
}
let subject_role = target_acl
.as_ref()
.map_or_else(|| VtcRole::Member.to_string(), |a| a.role.to_string());
let facts = assemble_leave_facts(
state,
actor_did,
target_did,
&subject_role,
target_member.as_ref(),
disposition,
&reason,
)
.await?;
let verified = VerifiedFacts::assemble(facts).map_err(AppError::from)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Removal,
)
.await?;
let allow = match decide(&verified, &policy)? {
Verdict::Allow(a) => a,
Verdict::Deny(d) => {
return Err(
AppError::Forbidden(format!("removal denied by policy ({})", d.code)).into(),
);
}
Verdict::Refer(_) | Verdict::RequestMore(_) => {
return Err(AppError::Internal(
"removal policy returned a non-terminal verdict; leave is synchronous".into(),
)
.into());
}
};
let initial = disposition
.or_else(|| target_member.as_ref().map(|m| m.departure_preference))
.unwrap_or(Disposition::PolicyDefault);
let resolved = match initial {
Disposition::PolicyDefault => allow
.disposition
.as_deref()
.and_then(parse_disposition_opt)
.unwrap_or(Disposition::Tombstone),
other => other,
};
let mut reduction = None;
if actor_did != target_did
&& let Some(prior) = target_acl.as_ref()
&& prior.is_administrator()
{
let Some(op) = op else {
return Err(AppError::Forbidden(format!(
"removing administrator {target_did} needs a passkey gesture bound to the \
operation (VTI-APV-019); send vtc/members/admin-remove"
))
.into());
};
if let Some(agreement) = crate::acl::admin_consent::settle_reduction(
state,
actor_did,
prior,
None,
op,
&format!("Remove administrator {target_did} from this community"),
&format!("Remove administrator {target_did} from this community"),
)
.await?
{
reduction = Some((op.type_uri, agreement));
}
}
let plan = EffectPlan::Depart {
subject: target_did.to_string(),
disposition: Some(disposition_wire(resolved).to_string()),
};
let EffectOutcome::Departed(outcome) = execute::apply(state, plan, actor_did).await? else {
return Err(
AppError::Internal("depart effect did not produce a departure outcome".into()).into(),
);
};
let disposition_str = disposition_wire(outcome.disposition);
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::MemberRemoved(MemberRemovedData {
disposition: disposition_str.into(),
reason: reason.clone(),
prior_role: target_acl.as_ref().map(|a| a.role.to_string()),
}),
)
.await?;
for grant in &outcome.revoked_grants {
crate::vetting::vetters::audit_revoked_grant(audit_writer, actor_did, target_did, grant)
.await?;
}
if let (Some((task, agreement)), Some(prior)) = (reduction, target_acl.as_ref()) {
crate::acl::admin_consent::after_reduction(
state, actor_did, prior, None, agreement, task, None, false,
)
.await?;
}
if let Some(slot) = outcome.revoked_slot {
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: StatusPurpose::Revocation.to_string(),
index: slot,
revoked: true,
}),
)
.await?;
}
info!(
actor = actor_did,
target = target_did,
disposition = disposition_str,
reason_present = !reason.is_empty(),
"member removed"
);
if actor_did != target_did {
crate::ceremony::removal_notice::send(
state,
target_did,
vta_sdk::protocols::members::RemovalCode::AdminRemoved,
disposition_str,
Some(reason.clone()),
&chrono::Utc::now().to_rfc3339(),
actor_did,
)
.await;
}
Ok(LeaveOutcome {
did: target_did.to_string(),
disposition: disposition_str.into(),
removed: true,
})
}
fn disposition_wire(d: Disposition) -> &'static str {
match d {
Disposition::Purge => "purge",
Disposition::Tombstone => "tombstone",
Disposition::Historical => "historical",
Disposition::PolicyDefault => "policydefault",
}
}
async fn assemble_leave_facts(
state: &AppState,
actor_did: &str,
subject_did: &str,
subject_role: &str,
subject_member: Option<&crate::members::Member>,
disposition: Option<Disposition>,
reason: &str,
) -> Result<super::Facts, AppError> {
let request = if disposition.is_some() || !reason.is_empty() {
let mut m = serde_json::Map::new();
if let Some(d) = disposition {
m.insert("disposition".into(), json!(disposition_wire(d)));
}
if !reason.is_empty() {
m.insert("reason".into(), json!(reason));
}
Some(serde_json::Value::Object(m))
} else {
None
};
assemble_facts(
state,
FactsInputs {
purpose: Purpose::Leave,
actor_did: actor_did.to_string(),
actor_role: load_actor_role(state, actor_did).await?,
subject_did: subject_did.to_string(),
subject_member: Some(member_state(subject_role.to_string(), subject_member)),
evidence: Evidence {
vetting: None,
invitation: None,
presentation: None,
request,
criterion: None,
},
thread_id: None,
},
)
.await
}
fn parse_disposition_opt(s: &str) -> Option<Disposition> {
match s {
"purge" => Some(Disposition::Purge),
"tombstone" => Some(Disposition::Tombstone),
"historical" => Some(Disposition::Historical),
_ => None,
}
}
#[cfg(test)]
mod p0_14_role_change_policy_tests {
use super::*;
use affinidi_status_list::StatusPurpose;
use chrono::Utc;
use vti_common::auth::extractor::AuthClaims;
use vti_common::auth::session::{Session, SessionState, now_epoch, store_session};
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, store_acl_entry};
use crate::members::{Member, store_member};
use crate::policy::{Policy, PolicyPurpose, set_active_policy_id, store_policy};
use crate::test_support::TestVtc;
const RP: &str = "https://vtc.example.com";
const ADMIN: &str = "did:key:zPromoter";
const SUBJECT: &str = "did:key:zCandidate";
async fn caller(vtc: &TestVtc, did: &str, elevated: bool) -> AuthClaims {
let session_id = format!("sess-{}", uuid::Uuid::new_v4());
store_session(
&vtc.state.sessions_ks,
&Session {
session_id: session_id.clone(),
did: did.to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now_epoch(),
last_seen: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: vec!["passkey".into()],
acr: "aal2".into(),
acr_expires_at: elevated.then(|| now_epoch() + 900),
token_id: None,
session_pubkey_b58btc: None,
not_after: None,
},
)
.await
.unwrap();
AuthClaims {
did: did.to_string(),
role: vti_common::acl::Role::Admin,
session_id,
amr: vec!["passkey".into()],
acr: "aal2".into(),
..Default::default()
}
}
async fn build() -> TestVtc {
let vtc = TestVtc::builder()
.with_signers(true)
.with_public_url(RP)
.build()
.await;
crate::policy::default::install_defaults(
&vtc.state.policies_ks,
&vtc.state.active_policies_ks,
)
.await
.expect("install default policies");
for purpose in [StatusPurpose::Revocation, StatusPurpose::Suspension] {
crate::status_list::ensure_initial(
&vtc.state.status_lists_ks,
purpose,
format!("{RP}/v1/status-lists/{purpose}"),
)
.await
.expect("ensure status list");
}
seed(&vtc, ADMIN, VtcRole::Admin).await;
seed(&vtc, SUBJECT, VtcRole::Member).await;
vtc
}
async fn promote(
vtc: &TestVtc,
actor: &AuthClaims,
subject: &str,
) -> Result<RoleChangeResult, AppError> {
let payload =
serde_json::json!({ "subject": subject, "fromRole": "member", "toRole": "admin" });
role_change_via_pipeline(
&vtc.state,
actor,
subject,
"member",
"admin",
Some(crate::acl::admin_consent::Operation {
type_uri: crate::trust_tasks::ACL_CHANGE_ROLE_TYPE,
payload: &payload,
}),
)
.await
}
async fn seed(vtc: &TestVtc, did: &str, role: VtcRole) {
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
did: did.into(),
admin: role.implied_authority(),
delegated_by: None,
role,
label: None,
created_at: crate::auth::session::now_epoch(),
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.unwrap();
store_member(&vtc.state.members_ks, &Member::fresh(did))
.await
.unwrap();
}
#[tokio::test]
async fn admin_promotion_without_a_live_step_up_is_refused() {
let vtc = build().await;
let actor = caller(&vtc, ADMIN, false).await;
let err = promote(&vtc, &actor, SUBJECT)
.await
.expect_err("an un-elevated session must not confer admin");
assert!(
matches!(err, AppError::StepUpRequired(_)),
"the refusal must be the step-up signal, got {err:?}"
);
let acl = get_acl_entry(&vtc.state.acl_ks, SUBJECT)
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Member, "a refused promotion writes");
}
#[tokio::test]
async fn self_promotion_is_refused_even_with_a_live_step_up() {
let vtc = build().await;
let actor = caller(&vtc, ADMIN, true).await;
seed(&vtc, "did:key:zSelf", VtcRole::Member).await;
let mut actor = actor;
actor.did = "did:key:zSelf".into();
let err = promote(&vtc, &actor, "did:key:zSelf")
.await
.expect_err("nobody promotes themselves");
match err {
AppError::Forbidden(msg) => assert!(
msg.contains("acl/change-role"),
"the refusal should name the replacement path, got {msg}"
),
other => panic!("expected Forbidden, got {other:?}"),
}
let acl = get_acl_entry(&vtc.state.acl_ks, "did:key:zSelf")
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Member);
}
#[tokio::test]
async fn a_promotion_racing_another_role_write_is_a_conflict() {
let vtc = build().await;
let actor = caller(&vtc, ADMIN, true).await;
seed(&vtc, "did:key:zRaced", VtcRole::Moderator).await;
let err = promote(&vtc, &actor, "did:key:zRaced")
.await
.expect_err("a stale current_role must not promote");
assert!(
matches!(err, AppError::Conflict(_)),
"expected a conflict, got {err:?}"
);
}
#[tokio::test]
async fn a_promotion_conferring_nothing_authority_conferring_needs_only_the_step_up() {
let vtc = build().await;
seed(&vtc, "did:key:zMod", VtcRole::Member).await;
let actor = caller(&vtc, ADMIN, true).await;
let payload = serde_json::json!({
"subject": "did:key:zMod", "fromRole": "member", "toRole": "moderator"
});
let granted = role_change_via_pipeline(
&vtc.state,
&actor,
"did:key:zMod",
"member",
"moderator",
Some(crate::acl::admin_consent::Operation {
type_uri: crate::trust_tasks::ACL_CHANGE_ROLE_TYPE,
payload: &payload,
}),
)
.await
.expect("the default policy allows it with a verified step-up");
assert_eq!(granted.new_role, "moderator");
let acl = get_acl_entry(&vtc.state.acl_ks, "did:key:zMod")
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Moderator);
assert_eq!(
acl.admin.admin_role,
Some(crate::acl::AdminRole::Moderator),
"the role carries the administrative role it implies"
);
}
#[tokio::test]
async fn vti_apv_014_promotion_to_unrestricted_admin_needs_a_second_party() {
let vtc = build().await;
let actor = caller(&vtc, ADMIN, true).await;
let err = promote(&vtc, &actor, SUBJECT)
.await
.expect_err("a sole admin cannot make another unrestricted admin alone");
match err {
AppError::Forbidden(msg) => assert!(msg.contains("vtc acl add"), "{msg}"),
other => panic!("expected Forbidden, got {other:?}"),
}
let acl = get_acl_entry(&vtc.state.acl_ks, SUBJECT)
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Member);
}
#[tokio::test]
async fn a_promotion_with_no_operation_to_consent_to_is_refused() {
let vtc = build().await;
let actor = caller(&vtc, ADMIN, true).await;
let err = role_change_via_pipeline(&vtc.state, &actor, SUBJECT, "member", "admin", None)
.await
.expect_err("no operation, no consent, no promotion");
match err {
AppError::Forbidden(msg) => assert!(msg.contains("acl/change-role"), "{msg}"),
other => panic!("expected Forbidden, got {other:?}"),
}
}
#[tokio::test]
async fn admin_promotion_is_403_when_policy_denies_even_with_step_up() {
let vtc = build().await;
let src = "package vtc.role_change\nimport rego.v1\n\
default decision := {\"effect\": \"deny\", \"with\": {\"code\": \"frozen\"}}\n";
let id = uuid::Uuid::new_v4();
let sha: [u8; 32] = {
use sha2::{Digest, Sha256};
Sha256::digest(src.as_bytes()).into()
};
store_policy(
&vtc.state.policies_ks,
&Policy {
id,
purpose: PolicyPurpose::RoleChange,
rego_source: src.into(),
sha256: sha,
activated_at: Some(Utc::now()),
author_did: "did:key:test".into(),
created_at: Utc::now(),
version: 1,
name: None,
description: None,
},
)
.await
.unwrap();
set_active_policy_id(&vtc.state.active_policies_ks, PolicyPurpose::RoleChange, id)
.await
.unwrap();
let actor = caller(&vtc, ADMIN, true).await;
let err = promote(&vtc, &actor, SUBJECT)
.await
.expect_err("a deny policy must block the promotion even after a valid UV");
assert!(
matches!(err, AppError::Forbidden(_)),
"deny → 403 Forbidden; got {err:?}"
);
let acl = get_acl_entry(&vtc.state.acl_ks, SUBJECT)
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Member, "denied promotion must not write");
}
}