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::get_acl_entry;
use crate::members::{Disposition, 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,
}
pub async fn role_change_via_pipeline(
state: &AppState,
actor_did: &str,
subject_did: &str,
current_role: &str,
target_role: &str,
step_up: bool,
) -> Result<RoleChangeResult, AppError> {
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) => {
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()))?;
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 Err(e) =
crate::credentials::delivery::deliver_credentials(state, subject_did, &[&outcome.role_vec])
.await
{
warn!(
subject = %subject_did,
error = %e,
"role-VEC delivery failed on role change; the credential is issued and can be re-delivered"
);
}
Ok(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 {
invitation: None,
presentation: None,
request: Some(json!({ "target_role": target_role, "step_up": step_up })),
},
},
)
.await
}
#[derive(Debug)]
pub struct LeaveOutcome {
pub did: String,
pub disposition: String,
pub removed: bool,
}
pub async fn remove_inner(
state: &AppState,
actor_did: &str,
target_did: &str,
disposition: Option<Disposition>,
reason: String,
) -> Result<LeaveOutcome, AppError> {
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?
.ok_or_else(|| AppError::NotFound(format!("member not found: {target_did}")))?;
let target_member = get_member(&state.members_ks, target_did).await?;
let facts = assemble_leave_facts(
state,
actor_did,
target_did,
&target_acl.role.to_string(),
target_member.as_ref(),
disposition,
&reason,
)
.await?;
let verified = VerifiedFacts::assemble(facts)?;
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
)));
}
Verdict::Refer(_) | Verdict::RequestMore(_) => {
return Err(AppError::Internal(
"removal policy returned a non-terminal verdict; leave is synchronous".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 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(),
));
};
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(),
}),
)
.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"
);
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 {
invitation: None,
presentation: None,
request,
},
},
)
.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 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 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 seed(vtc: &TestVtc, did: &str, role: VtcRole) {
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
did: did.into(),
role,
label: None,
allowed_contexts: vec![],
created_at: crate::auth::session::now_epoch(),
created_by: "did:key:vtc-install".into(),
expires_at: None,
},
)
.await
.unwrap();
store_member(&vtc.state.members_ks, &Member::fresh(did))
.await
.unwrap();
}
#[tokio::test]
async fn admin_promotion_with_step_up_is_allowed_by_default_policy() {
let vtc = build().await;
let granted = role_change_via_pipeline(
&vtc.state, ADMIN, SUBJECT, "member", "admin", true,
)
.await
.expect("default policy allows admin promotion with a verified step-up");
assert_eq!(granted.new_role, "admin");
assert_eq!(granted.previous_role, "member");
let acl = get_acl_entry(&vtc.state.acl_ks, SUBJECT)
.await
.unwrap()
.unwrap();
assert_eq!(acl.role, VtcRole::Admin);
}
#[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,
},
)
.await
.unwrap();
set_active_policy_id(&vtc.state.active_policies_ks, PolicyPurpose::RoleChange, id)
.await
.unwrap();
let err = role_change_via_pipeline(
&vtc.state, ADMIN, SUBJECT, "member", "admin", true,
)
.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");
}
}