use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::info;
use crate::acl::delegation::{self, DelegationReview};
use crate::acl::granting::{self, GrantRefusal};
use crate::acl::{
AdminAuthority, AdminRole, CapRef, Capability, CapabilityScope, VtcAclEntry, VtcActScope,
VtcRole, delete_acl_entry, get_acl_entry, list_acl_entries, store_acl_entry,
};
use crate::auth::{AuthClaims, session::now_epoch};
use crate::error::{AppError, TaskError};
use crate::members::get_member;
use crate::server::AppState;
use vti_common::acl::ContextDirection;
use vti_common::audit::{AclChangeData, AclRevokedData, AdminPromotedData, AuditEvent};
use vti_common::pagination::{Cursor, MAX_LIMIT};
pub(crate) const EXT_NS: &str = "org.openvtc";
pub(crate) const NO_ADMIN_ROLE: &str = "member";
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AclListResponse {
pub entries: Vec<AclEntryResponse>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AclEntryResponse {
pub subject: String,
pub role: VtcRole,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub scopes: Vec<String>,
pub created_at: String,
pub created_by: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AclEntryEnvelope {
pub entry: AclEntryResponse,
}
fn epoch_to_rfc3339(secs: u64) -> String {
chrono::DateTime::from_timestamp(secs as i64, 0)
.unwrap_or_default()
.to_rfc3339()
}
#[must_use]
pub(crate) fn expressible_in_v0_1(e: &VtcAclEntry) -> bool {
e.admin == e.role.implied_authority()
}
fn inexpressible(did: &str, verb: &str) -> AppError {
AppError::Conflict(format!(
"the ACL entry for {did} carries administrative authority acl/*/0.1 cannot express — a \
narrowed or qualified capability set, or a role its community role does not imply — \
so it cannot be {verb} at 0.1. Use the 0.2 task instead (acl/_shared/0.2 \
CONVENTIONS §8)"
))
}
pub(crate) fn render_v0_1(e: VtcAclEntry) -> AclEntryResponse {
AclEntryResponse::v0_1(e)
}
impl AclEntryResponse {
fn v0_1(e: VtcAclEntry) -> Self {
AclEntryResponse {
subject: e.did,
role: e.role,
label: e.label,
scopes: Vec::new(),
created_at: epoch_to_rfc3339(e.created_at),
created_by: e.created_by,
updated_at: e.updated_at.map(epoch_to_rfc3339),
updated_by: e.updated_by,
expires_at: e.expires_at.map(epoch_to_rfc3339),
}
}
}
pub(crate) fn render_v0_2(e: &VtcAclEntry, review: Option<&DelegationReview>) -> Value {
let mut out = json!({
"subject": e.did,
"role": e.admin.admin_role.as_ref().map(|r| r.to_string()).unwrap_or_else(|| NO_ADMIN_ROLE.into()),
"act": e.admin.act,
"keys": { "scope": "none" },
"capabilities": e.admin.capabilities,
"approve": e.admin.approve,
"approveCapabilities": e.admin.approve_capabilities,
"createdAt": epoch_to_rfc3339(e.created_at),
"createdBy": e.created_by,
});
let map = out.as_object_mut().expect("an object");
if let Some(label) = &e.label {
map.insert("label".into(), json!(label));
}
if let Some(by) = &e.delegated_by {
map.insert("delegatedBy".into(), json!(by));
}
if let Some(at) = e.updated_at {
map.insert("updatedAt".into(), json!(epoch_to_rfc3339(at)));
}
if let Some(by) = &e.updated_by {
map.insert("updatedBy".into(), json!(by));
}
if let Some(at) = e.expires_at {
map.insert("expiresAt".into(), json!(epoch_to_rfc3339(at)));
}
let mut ours = json!({ "communityRole": e.role.to_string() });
if e.label.is_some() && e.label_set_by_subject {
ours[LABEL_SET_BY_SUBJECT] = json!(true);
}
if !e.resource_grants.is_empty() {
ours["resourceGrants"] = Value::Array(
e.resource_grants
.iter()
.map(|g| {
let mut v = json!({
"capability": g.capability,
"resource": g.resource,
"delegatedBy": g.delegated_by,
"grantedAt": g.granted_at,
});
if let Some(grade) = g.grade {
v["grade"] = json!(grade);
}
if let Some(at) = g.expires_at {
v["expiresAt"] = json!(at);
}
if g.break_glass.is_some() {
v["breakGlass"] = json!(true);
}
if g.single_admin.is_some() {
v[crate::git_ns::single_admin::EXT_MARKER] = json!(true);
}
if let Some(r) = &g.review {
v["review"] = json!({ "granter": r.granter, "deadline": r.deadline });
}
v
})
.collect(),
);
}
if let Some(r) = review {
ours["delegationReview"] = json!({
"granter": r.granter,
"deadline": epoch_to_rfc3339(r.deadline),
});
}
if let Some(s) = &e.suspension {
ours["suspended"] = json!({
"actionId": s.action_id,
"landsAt": s.lands_at_rfc3339(),
"requester": s.requester,
});
}
map.insert("ext".into(), json!({ EXT_NS: ours }));
out
}
pub(crate) fn conform<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, AppError> {
serde_json::from_value(value)
.map_err(|e| AppError::Internal(format!("acl response does not conform: {e}")))
}
pub(crate) async fn reader_entry(state: &AppState, did: &str) -> Result<VtcAclEntry, AppError> {
match get_acl_entry(&state.acl_ks, did).await? {
Some(e) if e.is_administrator() && e.suspension.is_some() => {
Err(e.suspension.as_ref().expect("checked").refusal(did))
}
Some(e) if e.is_administrator() => Ok(e),
_ => Err(AppError::Forbidden(format!(
"{did} holds no administrative role, so it may not read this community's ACL"
))),
}
}
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
#[into_params(parameter_in = Query)]
pub struct ListAclQuery {
pub role: Option<String>,
pub scope: Option<String>,
pub subject_prefix: Option<String>,
#[param(inline)]
pub direction: Option<ContextDirection>,
pub page_size: Option<usize>,
pub cursor: Option<String>,
}
struct Page {
entries: Vec<VtcAclEntry>,
truncated: bool,
cursor: Option<String>,
}
async fn page(
state: &AppState,
matching: Vec<VtcAclEntry>,
page_size: Option<usize>,
cursor: Option<&str>,
binding: &[u8],
) -> Result<Page, AppError> {
let mut matching = matching;
matching.sort_by(|a, b| a.did.cmp(&b.did));
let limit = page_size.unwrap_or(50).clamp(1, MAX_LIMIT);
let audit_key = match state.audit_writer.as_ref() {
Some(w) => Some(w.active_key().await?),
None => None,
};
let start = match (cursor, &audit_key) {
(Some(wire), Some(key)) => {
let c = Cursor::decode_bound(wire, &key.key, binding)?;
matching
.iter()
.position(|e| e.did.as_bytes() > c.last_key.as_slice())
.unwrap_or(matching.len())
}
(Some(_), None) => return Err(AppError::InvalidCursor),
(None, _) => 0,
};
let take = if audit_key.is_some() {
limit
} else {
matching.len()
};
let entries: Vec<VtcAclEntry> = matching[start..].iter().take(take).cloned().collect();
let truncated = start + entries.len() < matching.len();
let cursor = match (&audit_key, truncated) {
(Some(key), true) => entries.last().map(|e| {
Cursor::new(e.did.as_bytes().to_vec(), matching.len() as u64)
.encode_bound(&key.key, binding)
}),
_ => None,
};
Ok(Page {
entries,
truncated,
cursor,
})
}
fn bind(fields: &[Option<&str>]) -> Vec<u8> {
let mut out = Vec::new();
for v in fields {
let b = v.unwrap_or("").as_bytes();
out.extend_from_slice(&(b.len() as u32).to_be_bytes());
out.extend_from_slice(b);
}
out
}
pub(crate) async fn list_entries(
state: &AppState,
actor: &AuthClaims,
query: &ListAclQuery,
) -> Result<AclListResponse, AppError> {
reader_entry(state, &actor.did).await?;
let matching: Vec<VtcAclEntry> = list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(expressible_in_v0_1)
.filter(|e| query.role.as_ref().is_none_or(|r| e.role.to_string() == *r))
.filter(|_| query.scope.is_none())
.filter(|e| {
query
.subject_prefix
.as_ref()
.is_none_or(|p| e.did.starts_with(p.as_str()))
})
.collect();
let binding = bind(&[
query.role.as_deref(),
query.scope.as_deref(),
query.subject_prefix.as_deref(),
Some(query.direction.unwrap_or_default().as_str()),
]);
let p = page(
state,
matching,
query.page_size,
query.cursor.as_deref(),
&binding,
)
.await?;
info!(caller = %actor.did, count = p.entries.len(), truncated = p.truncated, "ACL listed");
Ok(AclListResponse {
entries: p.entries.into_iter().map(AclEntryResponse::v0_1).collect(),
truncated: p.truncated,
cursor: p.cursor,
})
}
pub(crate) async fn list_entries_v0_2(
state: &AppState,
actor_did: &str,
q: &trust_tasks_rs::specs::acl::list::v0_2::Payload,
) -> Result<Value, AppError> {
reader_entry(state, actor_did).await?;
let capability: Option<Capability> = q.capability.as_deref().map(|c| c.parse()).transpose()?;
let resource: Option<crate::acl::ResourceQualifier> =
q.resource.as_deref().map(|r| r.parse()).transpose()?;
let direction_owned = q.direction.map(|d| d.to_string());
let direction = direction_owned.as_deref();
let role = q.role.as_deref().map(String::as_str);
let subject_prefix = q.subject_prefix.as_deref().map(String::as_str);
let matching: Vec<VtcAclEntry> = list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(|e| {
role.is_none_or(|r| {
e.admin
.admin_role
.as_ref()
.map(|a| a.to_string())
.unwrap_or_else(|| NO_ADMIN_ROLE.into())
== r
})
})
.filter(|e| {
q.context.is_none()
|| (matches!(direction, Some("actingIn" | "any")) && e.admin.act.is_all())
})
.filter(|e| {
let mut held: Vec<CapRef> = if e.admin.act.is_all() {
e.admin.effective()
} else {
vec![]
};
let now = chrono::Utc::now();
held.extend(
e.resource_grants
.iter()
.filter(|g| {
g.is_live(now)
&& matches!(
g.grade,
None | Some(crate::acl::resource_grant::RepoGrade::Own)
)
})
.map(crate::acl::resource_grant::ResourceGrant::cap_ref),
);
let of_kind = |c: &&CapRef| capability.is_none_or(|k| c.capability == k);
match &resource {
None => capability.is_none() || held.iter().any(|c| of_kind(&c)),
Some(r) => held.iter().filter(of_kind).any(|c| {
let acting_in = c.resource.as_ref().is_none_or(|h| h.covers(r));
let beneath = c.resource.as_ref().is_some_and(|h| r.covers(h));
match direction {
Some("actingIn") => acting_in,
Some("subtree") => beneath,
_ => acting_in || beneath,
}
}),
}
})
.filter(|e| subject_prefix.is_none_or(|p| e.did.starts_with(p)))
.collect();
let binding = bind(&[
Some("0.2"),
role,
q.context.as_deref().map(String::as_str),
q.capability.as_deref().map(String::as_str),
q.resource.as_deref().map(String::as_str),
direction,
subject_prefix,
]);
let page_size = q
.page_size
.map(|n| usize::try_from(n.get()).unwrap_or(usize::MAX));
let cursor = q.cursor.as_deref().map(String::as_str);
let p = page(state, matching, page_size, cursor, &binding).await?;
let reviews = delegation::list(state).await?;
let mut entries: Vec<Value> = Vec::with_capacity(p.entries.len());
for e in &p.entries {
let mut v = render_v0_2(e, reviews.iter().find(|r| r.subject == e.did));
with_membership(state, &mut v, &e.did).await?;
entries.push(v);
}
let mut out = json!({ "entries": entries, "truncated": p.truncated });
if let Some(c) = p.cursor {
out["cursor"] = json!(c);
}
info!(caller = %actor_did, count = entries.len(), "ACL listed (0.2)");
Ok(out)
}
pub(crate) async fn show_entry(
state: &AppState,
actor: &AuthClaims,
did: &str,
) -> Result<AclEntryEnvelope, AppError> {
reader_entry(state, &actor.did).await?;
let entry = get_acl_entry(&state.acl_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !expressible_in_v0_1(&entry) {
return Err(inexpressible(did, "shown"));
}
info!(caller = %actor.did, did = %did, "ACL entry retrieved");
Ok(AclEntryEnvelope {
entry: AclEntryResponse::v0_1(entry),
})
}
pub(crate) async fn show_entry_v0_2(
state: &AppState,
actor_did: &str,
did: &str,
) -> Result<Value, AppError> {
reader_entry(state, actor_did).await?;
let entry = get_acl_entry(&state.acl_ks, did).await?;
let review = delegation::review_for(state, did).await?;
let entry = match entry {
Some(e) => {
let mut v = render_v0_2(&e, review.as_ref());
with_membership(state, &mut v, &e.did).await?;
Some(v)
}
None => None,
};
Ok(json!({ "entry": entry }))
}
pub(crate) const MEMBERSHIP_EXT: &str = "membership";
async fn with_membership(
state: &AppState,
rendered: &mut Value,
did: &str,
) -> Result<(), AppError> {
let membership = match crate::members::get_member(&state.members_ks, did).await? {
Some(m) if m.is_removed() => "removed",
Some(_) => "active",
None => "none",
};
if let Some(ours) = rendered
.get_mut("ext")
.and_then(|e| e.get_mut(EXT_NS))
.and_then(Value::as_object_mut)
{
ours.insert(MEMBERSHIP_EXT.into(), json!(membership));
}
Ok(())
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateAclRequest {
pub entry: GrantEntry,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GrantEntry {
pub subject: String,
pub role: VtcRole,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
pub(crate) fn conferral_summary(subject: &str, conferred: &[CapRef]) -> String {
format!(
"Give {subject} {} — authority to create authority in this community",
conferred
.iter()
.map(CapRef::display)
.collect::<Vec<_>>()
.join(", ")
)
}
pub(crate) fn unrestricted_grant_summary(subject: &str) -> String {
format!("Make {subject} a community administrator of this community")
}
#[derive(Debug)]
pub(crate) enum WriteError {
Refused(GrantRefusal),
Task(TaskError),
}
impl From<AppError> for WriteError {
fn from(e: AppError) -> Self {
WriteError::Task(TaskError::App(e))
}
}
impl From<TaskError> for WriteError {
fn from(e: TaskError) -> Self {
WriteError::Task(e)
}
}
impl From<WriteError> for TaskError {
fn from(e: WriteError) -> Self {
match e {
WriteError::Task(t) => t,
WriteError::Refused(r) => TaskError::App(AppError::Forbidden(r.to_string())),
}
}
}
#[derive(Debug)]
pub(crate) struct GrantPlan {
pub(crate) entry: VtcAclEntry,
pub(crate) prior: Option<VtcAclEntry>,
status: StatusCode,
pub(crate) conferred: Vec<CapRef>,
pub(crate) widens: bool,
pub(crate) reduces_admin: Option<VtcAclEntry>,
ends_assigner: bool,
reduces: bool,
event: PlanEvent,
reason: Option<String>,
pub(crate) self_edit: SelfEdit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelfEdit {
No,
Label,
UnrestrictedInSingleAdminMode,
}
#[must_use]
pub(crate) fn only_label_differs(prev: &VtcAclEntry, next: &VtcAclEntry) -> bool {
prev.role == next.role && prev.admin == next.admin && prev.expires_at == next.expires_at
}
pub(crate) async fn other_unrestricted(
state: &AppState,
did: &str,
now: u64,
) -> Result<usize, AppError> {
Ok(list_acl_entries(&state.acl_ks)
.await?
.iter()
.filter(|e| e.did != did && granting::is_unrestricted(e, now))
.count())
}
pub(crate) fn self_edit_refusal(what: &str, single_admin_mode: bool) -> AppError {
let mode = if single_admin_mode {
" In single-administrator mode an administrator whose entry is unrestricted (a \
community-admin acting everywhere with its full ceiling) may edit its own entry with \
a passkey gesture bound to the change; yours is not."
} else {
""
};
AppError::Forbidden(format!(
"you cannot {what} (VTI-ACL-052) — you may change your entry's label yourself, but \
any other change must be made by another administrator holding vtc.roles.assign.{mode}"
))
}
pub(crate) fn own_role_refusal(single_admin_mode: bool) -> AppError {
let mode = if single_admin_mode {
" In single-administrator mode, an administrator whose entry is unrestricted edits its \
own entry with acl/update/0.2 or acl/change-role/0.2, on a passkey gesture bound to \
the change."
} else {
" Another administrator holding vtc.roles.assign can make this change."
};
AppError::Forbidden(format!(
"you cannot change your own role here (VTI-ACL-052) — you may change your entry's label \
yourself.{mode}"
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlanEvent {
Granted,
Updated,
}
async fn actor_entry(state: &AppState, did: &str) -> Result<VtcAclEntry, AppError> {
match get_acl_entry(&state.acl_ks, did).await? {
Some(e) if e.is_expired(now_epoch()) => Err(AppError::Forbidden(format!(
"your ACL entry ({did}) has expired; an expired entry confers no authority to grant \
(VTI-ACL-004)"
))),
Some(e) => Ok(e),
None => Err(AppError::Forbidden(format!(
"{did} has no ACL entry of its own, so there is no authority to bound this write by \
(VTI-ACL-001, VTI-ACL-053)"
))),
}
}
fn not_covered(did: &str, verb: &str) -> AppError {
AppError::Forbidden(format!(
"{did} holds authority outside yours — only an administrator holding vtc.roles.assign \
over everything it holds can {verb} it (VTI-ACL-050)"
))
}
pub(crate) async fn plan_write(
state: &AppState,
actor_did: &str,
mut next: VtcAclEntry,
existing: Option<VtcAclEntry>,
event: PlanEvent,
reason: Option<String>,
) -> Result<GrantPlan, WriteError> {
let now = now_epoch();
next.role.refuse_unassignable()?;
if next.did == actor_did {
return plan_self_write(state, next, existing, event, reason, now).await;
}
let actor = actor_entry(state, actor_did).await?;
if let Some(prev) = existing.as_ref()
&& !granting::covers_entry(&actor, prev, now)
{
return Err(not_covered(&prev.did, "rewrite").into());
}
crate::acl::roles::resolve(&state.acl_ks, &mut next).await?;
granting::check_write(&actor, &next, now).map_err(WriteError::Refused)?;
let (created_at, created_by, status) = match existing.as_ref() {
Some(prev) => (prev.created_at, prev.created_by.clone(), StatusCode::OK),
None => (now, actor.did.clone(), StatusCode::CREATED),
};
next.created_at = created_at;
next.created_by = created_by;
next.updated_at = (status == StatusCode::OK).then_some(now);
next.updated_by = (status == StatusCode::OK).then(|| actor.did.clone());
next.delegated_by = next.admin.is_administrator().then(|| actor.did.clone());
next.label_set_by_subject = existing
.as_ref()
.is_some_and(|p| p.label == next.label && p.label_set_by_subject);
let prior_live = existing.as_ref().filter(|p| !p.is_expired(now));
let conferred = crate::acl::admin_consent::newly_conferred(prior_live, &next, now);
let extends_life = existing
.as_ref()
.is_some_and(|p| match (p.expires_at, next.expires_at) {
(Some(_), None) => true,
(Some(was), Some(now)) => now > was,
(None, _) => false,
});
let shortens_life = existing
.as_ref()
.is_some_and(|p| match (p.expires_at, next.expires_at) {
(None, Some(_)) => true,
(Some(was), Some(now)) => now < was,
(_, None) => false,
});
let widens = next.admin.is_administrator()
&& match prior_live {
None => true,
Some(p) => next.admin.widens_from(&p.admin) || extends_life,
};
let reduces = prior_live.is_some_and(|p| next.admin.narrows_from(&p.admin) || shortens_life);
let reduces_admin = prior_live
.filter(|p| reduces && p.admin.is_administrator())
.cloned();
let ends_assigner = prior_live
.is_some_and(|p| crate::acl::admin_consent::is_live_role_assigner(p, now))
&& !crate::acl::admin_consent::is_live_role_assigner(&next, now);
Ok(GrantPlan {
entry: next,
prior: existing,
status,
conferred,
widens,
reduces_admin,
ends_assigner,
reduces,
event,
reason,
self_edit: SelfEdit::No,
})
}
async fn plan_self_write(
state: &AppState,
mut next: VtcAclEntry,
existing: Option<VtcAclEntry>,
event: PlanEvent,
reason: Option<String>,
now: u64,
) -> Result<GrantPlan, WriteError> {
let mode = crate::acl::admin_consent::single_admin_mode(state).await;
let Some(prev) = existing else {
return Err(self_edit_refusal("write your own ACL entry", mode).into());
};
let actor = actor_entry(state, &next.did).await?;
crate::acl::roles::resolve(&state.acl_ks, &mut next).await?;
let self_edit = if prev.label != next.label && only_label_differs(&prev, &next) {
SelfEdit::Label
} else if mode && granting::is_unrestricted(&prev, now) {
SelfEdit::UnrestrictedInSingleAdminMode
} else {
return Err(self_edit_refusal("change your own ACL entry beyond its label", mode).into());
};
if self_edit == SelfEdit::UnrestrictedInSingleAdminMode {
next.admin
.validate_against_ceiling()
.map_err(|e| WriteError::Refused(GrantRefusal::Ceiling(e)))?;
let shortens_life = match (prev.expires_at, next.expires_at) {
(None, Some(_)) => true,
(Some(was), Some(will)) => will < was,
(_, None) => false,
};
if (!granting::is_unrestricted(&next, now) || shortens_life)
&& other_unrestricted(state, &actor.did, now).await? == 0
{
return Err(AppError::Forbidden(format!(
"this change would leave the community with no entry holding unrestricted act \
scope (VTI-ACL-052 item 3): yours is its only one, so it must stay a \
community-admin acting everywhere with its full ceiling, and its life may not \
shorten. It would become: {}. Make another community administrator first",
crate::acl_cli::describe_authority(&next.admin)
))
.into());
}
}
next.created_at = prev.created_at;
next.created_by = prev.created_by.clone();
next.updated_at = Some(now);
next.updated_by = Some(actor.did.clone());
next.delegated_by = prev.delegated_by.clone();
next.label_set_by_subject = if prev.label == next.label {
prev.label_set_by_subject
} else {
next.label.is_some()
};
let reduces = next.admin.narrows_from(&prev.admin);
let ends_assigner = crate::acl::admin_consent::is_live_role_assigner(&prev, now)
&& !crate::acl::admin_consent::is_live_role_assigner(&next, now);
Ok(GrantPlan {
entry: next,
prior: Some(prev),
status: StatusCode::OK,
conferred: Vec::new(),
widens: false,
reduces_admin: None,
ends_assigner,
reduces,
event,
reason,
self_edit,
})
}
pub(crate) async fn commit_grant(
state: &AppState,
actor_did: &str,
plan: GrantPlan,
) -> Result<(StatusCode, VtcAclEntry), AppError> {
let GrantPlan {
entry,
prior,
status,
reason,
ends_assigner,
reduces,
event,
self_edit,
..
} = plan;
let custom_role = match entry.admin.admin_role.as_ref() {
Some(crate::acl::AdminRole::Custom(name)) => Some(name.clone()),
_ => None,
};
let _admin_set = if ends_assigner || custom_role.is_some() {
let guard = crate::ceremony::lock_admin_set().await;
if ends_assigner {
crate::acl::admin_consent::check_attrition(state, &entry.did).await?;
}
if let Some(name) = custom_role.as_deref()
&& crate::acl::roles::get(&state.acl_ks, name).await?.is_none()
{
return Err(AppError::Validation(format!(
"'{name}' is not a role this community can grant — it was deleted \
(VTI-ACL-011)"
)));
}
Some(guard)
} else {
None
};
let mut entry = entry;
{
let _git = crate::git_ns::store::write_lock().await;
entry.resource_grants = get_acl_entry(&state.acl_ks, &entry.did)
.await?
.map(|e| e.resource_grants)
.unwrap_or_default();
store_acl_entry(&state.acl_ks, &entry).await?;
}
crate::admin_actions::record_effect(state).await;
drop(_admin_set);
if reduces && self_edit == SelfEdit::No {
let revoked = super::auth::revoke_sessions_for_did(&state.sessions_ks, &entry.did).await?;
info!(did = %entry.did, revoked, "subject sessions revoked after ACL privilege reduction");
}
if self_edit == SelfEdit::No {
delegation::clear(state, &entry.did).await?;
}
if reduces {
delegation::on_granter_changed(state, &entry.did, Some(&entry)).await?;
}
if let Some(writer) = state.audit_writer.as_ref() {
let event = if self_edit == SelfEdit::Label {
AuditEvent::MemberUpdated(self_label_audit(prior.as_ref(), &entry))
} else {
let data = audit_data(&entry);
match event {
PlanEvent::Granted => AuditEvent::AclGranted(data),
PlanEvent::Updated => AuditEvent::AclUpdated(data),
}
};
writer.write(actor_did, Some(&entry.did), event).await?;
}
info!(
caller = %actor_did,
did = %entry.did,
role = %entry.role,
admin_role = ?entry.admin.admin_role,
reason = reason.as_deref().unwrap_or(""),
created = status == StatusCode::CREATED,
was = prior.is_some(),
task = ?event,
"ACL entry written",
);
Ok((status, entry))
}
pub(crate) fn self_label_audit(
prior: Option<&VtcAclEntry>,
entry: &VtcAclEntry,
) -> vti_common::audit::MemberUpdatedData {
use vti_common::audit::{FieldChange, MemberUpdatedData};
let old_label = prior.and_then(|p| p.label.clone());
let old_mark = prior.is_some_and(|p| p.label_set_by_subject);
let mut fields_changed = Vec::new();
let mut changes = Vec::new();
if old_label != entry.label {
fields_changed.push("label".to_string());
changes.push(FieldChange {
field: "label".into(),
old: old_label.map(Value::String),
new: entry.label.clone().map(Value::String),
});
}
fields_changed.push(LABEL_SET_BY_SUBJECT.to_string());
changes.push(FieldChange {
field: LABEL_SET_BY_SUBJECT.into(),
old: Some(Value::Bool(old_mark)),
new: Some(Value::Bool(entry.label_set_by_subject)),
});
MemberUpdatedData {
fields_changed,
changes,
}
}
pub(crate) const LABEL_SET_BY_SUBJECT: &str = "labelSetBySubject";
fn audit_data(e: &VtcAclEntry) -> AclChangeData {
AclChangeData {
did: e.did.clone(),
role: e
.admin
.admin_role
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| e.role.to_string()),
contexts: if e.admin.act.is_all() {
e.admin.effective().iter().map(CapRef::display).collect()
} else {
Vec::new()
},
expires_at: e.expires_at.map(|x| x.to_string()),
}
}
fn refuse_scopes(scopes: &[String]) -> Result<(), AppError> {
if scopes.is_empty() {
Ok(())
} else {
Err(AppError::Validation(format!(
"a community holds no contexts (VTI-VTC-010), so an entry cannot be scoped to {} — \
narrow administrative authority with acl/update/0.2 capabilities and qualifiers",
scopes.join(", ")
)))
}
}
pub(crate) async fn plan_grant(
state: &AppState,
actor: &AuthClaims,
req: CreateAclRequest,
) -> Result<GrantPlan, WriteError> {
let e = req.entry;
refuse_scopes(&e.scopes)?;
let existing = get_acl_entry(&state.acl_ks, &e.subject).await?;
if let Some(prev) = existing.as_ref() {
if prev.role != e.role {
return Err(AppError::Conflict(format!(
"ACL entry for {} already holds role {}; use acl/change-role to move it to {}",
e.subject, prev.role, e.role
))
.into());
}
if !expressible_in_v0_1(prev) {
return Err(inexpressible(&e.subject, "rewritten").into());
}
}
let mut next = VtcAclEntry::new(
e.subject,
e.role.clone(),
e.role.implied_authority(),
actor.did.clone(),
);
next.label = e.label;
next.expires_at = e.expires_at.map(|t| t.timestamp() as u64);
plan_write(
state,
&actor.did,
next,
existing,
PlanEvent::Granted,
req.reason,
)
.await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct UpdateEntryRequest {
pub subject: String,
#[serde(default, deserialize_with = "double_option")]
pub label: Option<Option<String>>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
#[serde(default, deserialize_with = "double_option")]
pub expires_at: Option<Option<DateTime<Utc>>>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub ext: Option<Value>,
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Deserialize::deserialize(de).map(Some)
}
async fn existing_for_update(
state: &AppState,
subject: &str,
not_found_code: &'static str,
) -> Result<VtcAclEntry, TaskError> {
get_acl_entry(&state.acl_ks, subject).await?.ok_or_else(|| {
TaskError::declared(
not_found_code,
AppError::NotFound(format!(
"ACL entry not found for DID: {subject} — acl/update amends an existing entry; \
use acl/grant to create one"
)),
)
})
}
pub(crate) async fn plan_update(
state: &AppState,
actor: &AuthClaims,
req: UpdateEntryRequest,
) -> Result<GrantPlan, WriteError> {
use trust_tasks_rs::specs::acl::update::v0_1::error_codes;
let existing = existing_for_update(state, &req.subject, error_codes::NOT_FOUND.code).await?;
if let Some(scopes) = req.scopes.as_ref() {
refuse_scopes(scopes)?;
}
if !expressible_in_v0_1(&existing) {
return Err(inexpressible(&req.subject, "updated").into());
}
let mut next = existing.clone();
if let Some(label) = req.label {
next.label = label;
}
if let Some(exp) = req.expires_at {
next.expires_at = exp.map(|t| t.timestamp() as u64);
}
plan_write(
state,
&actor.did,
next,
Some(existing),
PlanEvent::Updated,
req.reason,
)
.await
}
pub(crate) enum EntryParseError {
RoleNotRecognized(String),
InvalidActScope(&'static str, Vec<String>),
UnknownCapability(Vec<String>),
Malformed(String),
}
pub(crate) fn parse_admin_role(role: &str) -> Result<Option<AdminRole>, EntryParseError> {
if role == NO_ADMIN_ROLE {
return Ok(None);
}
match role.parse::<AdminRole>() {
Ok(r) => Ok(Some(r)),
_ => Err(EntryParseError::RoleNotRecognized(role.to_string())),
}
}
pub(crate) fn parse_scope(v: &Value, member: &'static str) -> Result<VtcActScope, EntryParseError> {
match v.get("scope").and_then(Value::as_str) {
Some("all") => Ok(VtcActScope::All),
Some("none") => Ok(VtcActScope::None),
Some("contexts") => Err(EntryParseError::InvalidActScope(
member,
v.get("contexts")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|c| c.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
)),
_ => Err(EntryParseError::Malformed(format!(
"`{member}` is not an explicit scope"
))),
}
}
pub(crate) fn parse_capabilities(v: &Value) -> Result<CapabilityScope, EntryParseError> {
if v.get("scope").and_then(Value::as_str) == Some("listed")
&& let Some(grants) = v.get("grants").and_then(Value::as_array)
{
let unknown: Vec<String> = grants
.iter()
.filter_map(|g| g.get("capability").and_then(Value::as_str))
.filter(|c| c.parse::<Capability>().is_err())
.map(str::to_string)
.collect();
if !unknown.is_empty() {
return Err(EntryParseError::UnknownCapability(unknown));
}
}
serde_json::from_value(v.clone()).map_err(|e| EntryParseError::Malformed(e.to_string()))
}
fn parse_keys(v: &Value) -> Result<(), EntryParseError> {
match v.get("scope").and_then(Value::as_str) {
Some("none") => Ok(()),
_ => Err(EntryParseError::Malformed(
"this community operates no signing oracle, so `keys` is {\"scope\": \"none\"}".into(),
)),
}
}
pub(crate) fn ext_community_role(ext: Option<&Value>) -> Result<Option<VtcRole>, EntryParseError> {
match ext
.and_then(|e| e.get(EXT_NS))
.and_then(|o| o.get("communityRole"))
{
None => Ok(None),
Some(Value::String(s)) => s
.parse()
.map(Some)
.map_err(|e: AppError| EntryParseError::Malformed(e.to_string())),
Some(_) => Err(EntryParseError::Malformed(
"ext.org.openvtc.communityRole is a string".into(),
)),
}
}
pub(crate) fn entry_from_v0_2(e: &Value) -> Result<VtcAclEntry, EntryParseError> {
let subject = e
.get("subject")
.and_then(Value::as_str)
.ok_or_else(|| EntryParseError::Malformed("entry.subject".into()))?;
let role = parse_admin_role(e.get("role").and_then(Value::as_str).unwrap_or_default())?;
let act = parse_scope(&e["act"], "act")?;
parse_keys(&e["keys"])?;
let capabilities = parse_capabilities(&e["capabilities"])?;
let approve = match e.get("approve") {
None => VtcActScope::None,
Some(v) => parse_scope(v, "approve")?,
};
let approve_capabilities = match e.get("approveCapabilities") {
None => CapabilityScope::None,
Some(v) => parse_capabilities(v)?,
};
if e.get("stepUp").is_some() {
return Err(EntryParseError::Malformed(
"this community keeps no per-entry step-up configuration — its step-up is bound to \
each operation"
.into(),
));
}
let community =
ext_community_role(e.get("ext"))?.unwrap_or_else(|| VtcRole::implied_by(role.as_ref()));
let mut entry = VtcAclEntry::new(
subject,
community,
AdminAuthority {
admin_role: role,
act,
capabilities,
approve,
approve_capabilities,
custom: None,
},
"",
);
entry.label = e.get("label").and_then(Value::as_str).map(str::to_string);
entry.expires_at = e
.get("expiresAt")
.and_then(Value::as_str)
.map(|s| {
DateTime::parse_from_rfc3339(s)
.map(|t| t.timestamp() as u64)
.map_err(|e| EntryParseError::Malformed(format!("expiresAt: {e}")))
})
.transpose()?;
Ok(entry)
}
pub(crate) async fn plan_grant_v0_2(
state: &AppState,
actor_did: &str,
next: VtcAclEntry,
reason: Option<String>,
) -> Result<GrantPlan, WriteError> {
let existing = get_acl_entry(&state.acl_ks, &next.did).await?;
if let Some(prev) = existing.as_ref() {
if prev.admin.admin_role != next.admin.admin_role {
return Err(AppError::Forbidden(format!(
"{} already holds role {}; role changes use acl/change-role/0.2",
next.did,
role_string(prev)
))
.into());
}
if next.admin.narrows_from(&prev.admin) {
return Err(AppError::Forbidden(format!(
"this grant would narrow {}'s entry; a grant never narrows — use acl/update/0.2 \
or acl/revoke/0.2",
next.did
))
.into());
}
}
plan_write(state, actor_did, next, existing, PlanEvent::Granted, reason).await
}
pub(crate) fn role_string(e: &VtcAclEntry) -> String {
e.admin
.admin_role
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| NO_ADMIN_ROLE.into())
}
pub(crate) async fn plan_update_v0_2(
state: &AppState,
actor_did: &str,
payload: &Value,
) -> Result<GrantPlan, UpdateV02Error> {
use trust_tasks_rs::specs::acl::update::v0_2::error_codes;
let subject = payload["subject"].as_str().unwrap_or_default().to_string();
let existing = existing_for_update(state, &subject, error_codes::NOT_FOUND.code)
.await
.map_err(|e| UpdateV02Error::Write(e.into()))?;
let mut next = existing.clone();
if let Some(v) = payload.get("act") {
let act = parse_scope(v, "act").map_err(UpdateV02Error::Parse)?;
if existing.admin.act.is_all() && act == VtcActScope::None {
return Err(UpdateV02Error::NarrowingNotPermitted);
}
next.admin.act = act;
}
if let Some(v) = payload.get("approve") {
next.admin.approve = parse_scope(v, "approve").map_err(UpdateV02Error::Parse)?;
}
if let Some(v) = payload.get("capabilities") {
next.admin.capabilities = parse_capabilities(v).map_err(UpdateV02Error::Parse)?;
}
if let Some(v) = payload.get("approveCapabilities") {
next.admin.approve_capabilities = parse_capabilities(v).map_err(UpdateV02Error::Parse)?;
}
if let Some(v) = payload.get("keys") {
parse_keys(v).map_err(UpdateV02Error::Parse)?;
}
if payload.get("stepUp").is_some() {
return Err(UpdateV02Error::Parse(EntryParseError::Malformed(
"this community keeps no per-entry step-up configuration".into(),
)));
}
match payload.get("label") {
None => {}
Some(Value::Null) => next.label = None,
Some(v) => next.label = v.as_str().map(str::to_string),
}
match payload.get("expiresAt") {
None => {}
Some(Value::Null) => next.expires_at = None,
Some(v) => {
next.expires_at = Some(
v.as_str()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp() as u64)
.ok_or_else(|| {
UpdateV02Error::Parse(EntryParseError::Malformed(
"expiresAt is an RFC 3339 date-time".into(),
))
})?,
)
}
}
if let Some(role) = ext_community_role(payload.get("ext")).map_err(UpdateV02Error::Parse)? {
next.role = role;
}
let reason = payload["reason"].as_str().map(str::to_string);
plan_write(
state,
actor_did,
next,
Some(existing),
PlanEvent::Updated,
reason,
)
.await
.map_err(UpdateV02Error::Write)
}
pub(crate) enum UpdateV02Error {
Parse(EntryParseError),
NarrowingNotPermitted,
Write(WriteError),
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAclRequest {
pub from_role: VtcRole,
pub to_role: VtcRole,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug)]
pub(crate) enum ChangeRoleOutcome {
Changed(Box<AclEntryEnvelope>),
StepUpRequired(Box<crate::acl::bound_step_up::ApproveRequest>),
}
pub(crate) async fn change_role_inner(
state: &AppState,
actor: &AuthClaims,
did: &str,
req: UpdateAclRequest,
source: crate::ceremony::StepUpSource<'_>,
) -> Result<ChangeRoleOutcome, AppError> {
let did = did.to_string();
if actor.did == did {
return Err(own_role_refusal(
crate::acl::admin_consent::single_admin_mode(state).await,
));
}
let op_payload = {
let mut v = serde_json::to_value(&req)
.map_err(|e| AppError::Internal(format!("serialise acl/change-role body: {e}")))?;
if let Some(map) = v.as_object_mut() {
map.insert("subject".into(), Value::String(did.clone()));
}
v
};
let acl = state.acl_ks.clone();
let entry = get_acl_entry(&acl, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if entry.role != req.from_role {
return Err(AppError::Conflict(format!(
"state mismatch: {did} currently holds role {}, not {}",
entry.role, req.from_role
)));
}
if !expressible_in_v0_1(&entry) {
return Err(inexpressible(&did, "moved"));
}
let actor_e = actor_entry(state, &actor.did).await?;
if !granting::covers_entry(&actor_e, &entry, now_epoch()) {
return Err(not_covered(&did, "change the role of"));
}
req.to_role.refuse_unassignable()?;
let promoting = matches!(req.to_role, VtcRole::Admin);
let granted = match source {
crate::ceremony::StepUpSource::Session { .. } => {
crate::ceremony::role_change_via_pipeline(
state,
actor,
&did,
&entry.role.to_string(),
&req.to_role.to_string(),
Some(crate::acl::admin_consent::Operation {
type_uri: crate::trust_tasks::ACL_CHANGE_ROLE_TYPE,
payload: &op_payload,
}),
)
.await?
}
crate::ceremony::StepUpSource::BoundTo { type_uri, payload } => {
match crate::ceremony::role_change_via_bound_step_up(
state,
actor,
&did,
&entry.role.to_string(),
&req.to_role.to_string(),
type_uri,
payload,
)
.await?
{
crate::ceremony::RoleChangeOutcome::Changed(result) => result,
crate::ceremony::RoleChangeOutcome::StepUpRequired(request) => {
return Ok(ChangeRoleOutcome::StepUpRequired(request));
}
}
}
};
let mut written = get_acl_entry(&acl, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
written.updated_at = Some(now_epoch());
written.updated_by = Some(actor.did.clone());
if written.admin.is_administrator() && written.admin != entry.admin {
written.delegated_by = Some(actor.did.clone());
}
store_acl_entry(&acl, &written).await?;
if written.admin.is_administrator() {
ensure_admin_sister_record(state, &did).await?;
}
let reduced = written.admin.narrows_from(&entry.admin);
if reduced {
let revoked = super::auth::revoke_sessions_for_did(&state.sessions_ks, &did).await?;
info!(did = %did, revoked, "subject sessions revoked after ACL privilege reduction");
delegation::on_granter_changed(state, &did, Some(&written)).await?;
}
delegation::clear(state, &did).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&actor.did,
Some(&did),
AuditEvent::AclUpdated(audit_data(&written)),
)
.await?;
if promoting {
writer
.write(
&actor.did,
Some(&did),
AuditEvent::AdminPromoted(AdminPromotedData {
previous_role: granted.previous_role.clone(),
authorising_credential_id: String::new(),
authorising_session_id: actor.session_id.clone(),
}),
)
.await?;
}
}
info!(
did = %did,
from = %granted.previous_role,
to = %granted.new_role,
reason = req.reason.as_deref().unwrap_or(""),
"ACL role changed",
);
Ok(ChangeRoleOutcome::Changed(Box::new(AclEntryEnvelope {
entry: AclEntryResponse::v0_1(written),
})))
}
pub(crate) async fn ensure_admin_sister_record(
state: &AppState,
did: &str,
) -> Result<(), AppError> {
use crate::acl::admin::{AdminEntry, get_admin_entry, store_admin_entry};
if get_admin_entry(&state.passkey_ks, did).await?.is_none() {
store_admin_entry(
&state.passkey_ks,
&AdminEntry {
did: did.to_string(),
passkeys: Vec::new(),
extensions: Value::Null,
created_at: Utc::now(),
},
)
.await?;
}
Ok(())
}
pub(crate) async fn plan_change_role_v0_2(
state: &AppState,
actor_did: &str,
subject: &str,
from: &str,
to: Option<AdminRole>,
) -> Result<GrantPlan, ChangeRoleV02Error> {
let existing = get_acl_entry(&state.acl_ks, subject)
.await?
.ok_or_else(|| {
ChangeRoleV02Error::Write(
AppError::NotFound(format!("ACL entry not found for DID: {subject}")).into(),
)
})?;
let current = role_string(&existing);
if current != from {
return Err(ChangeRoleV02Error::StateMismatch(current));
}
let mut next = existing.clone();
next.admin.admin_role = to;
plan_write(
state,
actor_did,
next,
Some(existing),
PlanEvent::Updated,
None,
)
.await
.map_err(ChangeRoleV02Error::Write)
}
pub(crate) enum ChangeRoleV02Error {
StateMismatch(String),
Write(WriteError),
}
impl From<AppError> for ChangeRoleV02Error {
fn from(e: AppError) -> Self {
ChangeRoleV02Error::Write(e.into())
}
}
fn revoke_response(
entry: Option<AclEntryResponse>,
) -> Result<vta_sdk::openapi::AclRevoke01Response, TaskError> {
serde_json::to_value(&entry)
.and_then(|entry| serde_json::from_value(json!({ "entry": entry })))
.map(vta_sdk::openapi::AclRevoke01Response)
.map_err(|e| AppError::Internal(format!("acl/revoke response: {e}")).into())
}
pub(crate) async fn revoke_entry(
state: &AppState,
actor: &AuthClaims,
did: &str,
scopes: Option<&[String]>,
reason: Option<&str>,
op: crate::acl::admin_consent::Operation<'_>,
) -> Result<vta_sdk::openapi::AclRevoke01Response, TaskError> {
use trust_tasks_rs::specs::acl::revoke::v0_1::error_codes;
if let Some(scopes) = scopes {
return Err(AppError::NotFound(format!(
"none of the requested scopes ({}) are held by {did}: a community entry holds no \
contexts (VTI-VTC-010)",
scopes.join(", ")
))
.into());
}
remove_entry(
state,
&actor.did,
did,
reason,
op,
error_codes::SUBJECT_NOT_PRESENT.code,
error_codes::LAST_AUTHORITY_PROTECTED.code,
)
.await?;
revoke_response(None)
}
pub(crate) async fn remove_entry(
state: &AppState,
actor_did: &str,
did: &str,
reason: Option<&str>,
op: crate::acl::admin_consent::Operation<'_>,
not_present_code: &'static str,
last_authority_code: &'static str,
) -> Result<(), TaskError> {
let did = did.to_string();
if actor_did == did {
return Err(AppError::Conflict("cannot delete your own ACL entry".into()).into());
}
let acl = state.acl_ks.clone();
let not_present = || {
TaskError::declared(
not_present_code,
AppError::NotFound(format!("ACL entry not found for DID: {did}")),
)
};
let Some(entry) = get_acl_entry(&acl, &did).await? else {
return Err(not_present());
};
let actor = actor_entry(state, actor_did).await?;
if !granting::covers_entry(&actor, &entry, now_epoch()) {
return Err(not_covered(&did, "revoke").into());
}
let prior = entry.clone();
if let Some(member) = get_member(&state.members_ks, &did).await?
&& !member.is_removed()
{
return Err(AppError::Conflict(format!(
"{did} is a member of this community — revoking their ACL entry would leave the \
member row with no authorization and their membership credentials unrevoked. \
To remove them from the community, use the leave ceremony instead: \
vtc/members/admin-remove for {did}"
))
.into());
}
let attrition = |e: AppError| match e {
AppError::Conflict(_) => TaskError::declared(last_authority_code, e),
other => TaskError::App(other),
};
let now = now_epoch();
if crate::acl::admin_consent::is_live_role_assigner(&prior, now) {
crate::acl::admin_consent::check_attrition(state, &did)
.await
.map_err(attrition)?;
}
let agreement = crate::acl::admin_consent::settle_reduction(
state,
actor_did,
&prior,
None,
op,
&format!("Remove administrator {did} from this community's ACL"),
&format!("Remove administrator {did}"),
)
.await?;
let _admin_set = crate::ceremony::lock_admin_set().await;
if let Some(live) = get_acl_entry(&acl, &did).await?
&& crate::acl::admin_consent::is_live_role_assigner(&live, now_epoch())
{
crate::acl::admin_consent::check_attrition(state, &did)
.await
.map_err(attrition)?;
}
delete_acl_entry(&acl, &did).await?;
crate::admin_actions::record_effect(state).await;
drop(_admin_set);
crate::rooms::departures::on_departure(
state,
&did,
crate::rooms::community::DepartureCause::AclRemoved,
)
.await?;
let revoked = super::auth::revoke_sessions_for_did(&state.sessions_ks, &did).await?;
delegation::clear(state, &did).await?;
delegation::on_granter_changed(state, &did, None).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor_did,
Some(&did),
AuditEvent::AclRevoked(AclRevokedData {
did: did.clone(),
prior_role: Some(role_string(&entry)),
}),
)
.await?;
}
if let Some(agreement) = agreement {
crate::acl::admin_consent::after_reduction(
state,
actor_did,
&prior,
None,
agreement,
op.type_uri,
reason,
true,
)
.await?;
}
info!(
caller = %actor_did,
did = %did,
revoked,
reason = reason.unwrap_or(""),
"ACL entry revoked",
);
Ok(())
}
pub(crate) async fn plan_narrow_act(
state: &AppState,
actor_did: &str,
did: &str,
act: VtcActScope,
) -> Result<GrantPlan, NarrowActError> {
let existing = get_acl_entry(&state.acl_ks, did)
.await?
.ok_or(NarrowActError::NotPresent)?;
if !(existing.admin.act.is_all() && act == VtcActScope::None) {
return Err(NarrowActError::NotNarrowing);
}
let mut next = existing.clone();
next.admin.act = act;
plan_write(
state,
actor_did,
next,
Some(existing),
PlanEvent::Updated,
None,
)
.await
.map_err(NarrowActError::Write)
}
pub(crate) enum NarrowActError {
NotPresent,
NotNarrowing,
Write(WriteError),
}
impl From<AppError> for NarrowActError {
fn from(e: AppError) -> Self {
NarrowActError::Write(e.into())
}
}
pub(crate) async fn caller_covers_target(
state: &AppState,
caller_did: &str,
target: &VtcAclEntry,
) -> Result<bool, AppError> {
Ok(match get_acl_entry(&state.acl_ks, caller_did).await? {
Some(c) => granting::covers_entry(&c, target, now_epoch()),
None => false,
})
}
#[derive(Debug)]
pub(crate) enum SwapError {
NotHolder(String),
SubjectNotFound(String),
SubjectAlreadyInUse(String),
LinkProofRequired,
LinkProofInvalid(&'static str, String),
App(AppError),
}
impl From<AppError> for SwapError {
fn from(e: AppError) -> Self {
SwapError::App(e)
}
}
const LINK_PROOF_MAX_TTL_SECS: u64 = 900;
pub(crate) async fn swap_key(
state: &AppState,
signer: &str,
current: &str,
new: &str,
link_proof: Option<&Value>,
reason: Option<&str>,
) -> Result<(VtcAclEntry, u32), SwapError> {
if signer != current {
return Err(SwapError::NotHolder(format!(
"the document is signed by {signer}, not by {current}: only an entry's own subject \
rolls it to a new key (VTI-CLT-027)"
)));
}
if current == new {
return Err(
AppError::Validation("newSubject must differ from currentSubject".into()).into(),
);
}
let now = now_epoch();
let Some(entry) = get_acl_entry(&state.acl_ks, current).await? else {
return Err(SwapError::SubjectNotFound(format!(
"{current} has no ACL entry"
)));
};
if entry.is_expired(now) {
return Err(SwapError::SubjectNotFound(format!(
"{current}'s ACL entry has expired; an expired entry confers nothing to roll \
(VTI-ACL-004)"
)));
}
if get_acl_entry(&state.acl_ks, new).await?.is_some()
|| get_member(&state.members_ks, new).await?.is_some()
{
return Err(SwapError::SubjectAlreadyInUse(format!(
"{new} already has an ACL entry or a membership in this community"
)));
}
let proof = match link_proof {
None => return Err(SwapError::LinkProofRequired),
Some(Value::String(s)) => s.clone(),
Some(_) => {
return Err(SwapError::LinkProofInvalid(
"format_unsupported",
"this community accepts a link proof as a compact VP-JWT (an \
AclSwapRequest presentation signed by newSubject)"
.into(),
));
}
};
verify_link_proof(state, &proof, new, now).await?;
let mut successor = entry.clone();
successor.did = new.to_string();
successor.updated_at = Some(now);
successor.updated_by = Some(current.to_string());
let _admin_set = crate::ceremony::lock_admin_set().await;
let old_key = format!("acl:{current}");
let Some(expected) = state.acl_ks.get_raw(old_key.as_bytes()).await? else {
return Err(SwapError::SubjectNotFound(format!(
"{current} has no ACL entry"
)));
};
let writer = state.audit_writer.as_ref().ok_or_else(|| {
AppError::Internal("the audit log is not available, so nothing was rotated".into())
})?;
writer
.write(
current,
Some(new),
AuditEvent::AclKeyRotated(vti_common::audit::AclKeyRotatedData {
old_did: current.to_string(),
new_did: new.to_string(),
role: role_string(&entry),
reason: reason.map(str::to_string),
}),
)
.await
.map_err(|e| {
AppError::Internal(format!(
"the rotation could not be audited, so it was not made: {e}"
))
})?;
match state
.acl_ks
.move_if_unchanged(old_key, expected, format!("acl:{new}"), &successor)
.await?
{
vti_common::store::MoveOutcome::Moved => {}
vti_common::store::MoveOutcome::SourceMissing => {
return Err(SwapError::SubjectNotFound(format!(
"{current}'s entry was moved or removed concurrently"
)));
}
vti_common::store::MoveOutcome::SourceChanged => {
return Err(AppError::Conflict(format!(
"{current}'s entry changed while it was being rotated; nothing was moved — send \
it again"
))
.into());
}
vti_common::store::MoveOutcome::TargetExists => {
return Err(SwapError::SubjectAlreadyInUse(format!(
"{new} gained an ACL entry concurrently"
)));
}
}
drop(_admin_set);
if let Some(mut m) = get_member(&state.members_ks, current).await? {
m.did = new.to_string();
if !state
.members_ks
.swap(
format!("members:{current}").into_bytes(),
format!("members:{new}").into_bytes(),
&m,
)
.await?
{
tracing::warn!(
current,
new,
"the member row could not follow the rotated entry"
);
}
}
let repointed = delegation::repoint(state, current, new).await?;
let revoked = super::auth::revoke_sessions_for_did(&state.sessions_ks, current).await?;
info!(
old = %current,
new = %new,
repointed,
revoked,
"ACL entry rolled to a new key (acl/swap-key)"
);
let mut written = successor;
crate::acl::roles::resolve(&state.acl_ks, &mut written).await?;
Ok((written, repointed))
}
async fn verify_link_proof(
state: &AppState,
jws: &str,
new: &str,
now: u64,
) -> Result<(), SwapError> {
use vta_sdk::protocols::acl_management::swap::{AclSwapError, AclSwapPresentation};
let invalid = |reason: &'static str, m: String| SwapError::LinkProofInvalid(reason, m);
let presentation = AclSwapPresentation::new(jws);
let holder = presentation
.peek_holder()
.map_err(|e| invalid("format_unsupported", e.to_string()))?;
if holder != new {
return Err(invalid(
"subject_mismatch",
format!("the link proof is {holder}'s, not newSubject {new}'s"),
));
}
if let Some(exp) = link_proof_exp(jws)
&& exp > now.saturating_add(LINK_PROOF_MAX_TTL_SECS)
{
return Err(invalid(
"expired",
format!(
"the link proof is valid until {exp}, longer than the {LINK_PROOF_MAX_TTL_SECS} s \
this community accepts (VTI-CLT-026: short-lived)"
),
));
}
let audience = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| AppError::Internal("this community has no DID to be addressed by".into()))?;
let doc = new_subject_document(state, new).await?;
presentation
.verify(&doc, &audience, now)
.map(|_| ())
.map_err(|e| match e {
AclSwapError::Expired { .. } => invalid("expired", e.to_string()),
AclSwapError::WrongAudience { .. } => invalid("nonce_mismatch", e.to_string()),
AclSwapError::HolderMismatch => invalid("subject_mismatch", e.to_string()),
AclSwapError::Signature(_) => invalid("signature_invalid", e.to_string()),
other => invalid("format_unsupported", other.to_string()),
})
}
fn link_proof_exp(jws: &str) -> Option<u64> {
use base64::Engine as _;
let payload = jws.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
serde_json::from_slice::<Value>(&bytes).ok()?["exp"].as_u64()
}
async fn new_subject_document(state: &AppState, new: &str) -> Result<Value, SwapError> {
if let Some(mb) = new.strip_prefix("did:key:") {
return Ok(json!({
"id": new,
"verificationMethod": [{
"id": format!("{new}#{mb}"),
"type": "Multikey",
"controller": new,
"publicKeyMultibase": mb,
}],
}));
}
let resolver = state.did_resolver.as_ref().ok_or_else(|| {
SwapError::LinkProofInvalid(
"format_unsupported",
format!("{new} cannot be resolved here: this community has no DID resolver"),
)
})?;
let resolved = resolver.resolve(new).await.map_err(|e| {
SwapError::LinkProofInvalid("signature_invalid", format!("resolve {new}: {e}"))
})?;
serde_json::to_value(&resolved.doc)
.map_err(|e| AppError::Internal(format!("serialise {new}'s DID document: {e}")).into())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(role: VtcRole, admin: AdminAuthority) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zABC".into(),
role,
label: Some("test".into()),
admin,
delegated_by: None,
created_at: 1_700_000_000,
created_by: "did:key:zSetup".into(),
updated_at: None,
updated_by: None,
expires_at: Some(1_800_000_000),
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
}
}
#[test]
fn grant_request_parses_minimal_body() {
let body = json!({ "entry": { "subject": "did:key:zABC", "role": "admin" } });
let req: CreateAclRequest = serde_json::from_value(body).expect("minimal body");
assert_eq!(req.entry.subject, "did:key:zABC");
assert_eq!(req.entry.role, VtcRole::Admin);
assert!(req.entry.scopes.is_empty(), "defaults to empty");
}
#[test]
fn grant_request_rejects_caller_supplied_provenance() {
for field in ["createdAt", "createdBy", "updatedAt", "updatedBy"] {
let body = json!({
"entry": { "subject": "did:key:zA", "role": "admin", field: "x" }
});
serde_json::from_value::<CreateAclRequest>(body)
.expect_err(&format!("{field} must not be accepted"));
}
}
#[test]
fn grant_request_rejects_unknown_role() {
let body = json!({ "entry": { "subject": "did:key:zA", "role": "godmode" } });
serde_json::from_value::<CreateAclRequest>(body).expect_err("unknown role must not parse");
}
#[test]
fn change_role_request_requires_both_roles() {
let ok: UpdateAclRequest =
serde_json::from_value(json!({ "fromRole": "member", "toRole": "moderator" }))
.expect("both roles");
assert_eq!(ok.to_role, VtcRole::Moderator);
serde_json::from_value::<UpdateAclRequest>(json!({ "toRole": "admin" }))
.expect_err("fromRole is mandatory");
}
#[test]
fn vti_vtc_010_a_0_1_scope_list_is_refused() {
assert!(refuse_scopes(&[]).is_ok());
assert!(matches!(
refuse_scopes(&["ctx-a".into()]),
Err(AppError::Validation(_))
));
}
#[test]
fn only_a_conventional_entry_is_expressible_in_0_1() {
assert!(expressible_in_v0_1(&entry(
VtcRole::Admin,
AdminAuthority::community_admin()
)));
assert!(expressible_in_v0_1(&entry(
VtcRole::Member,
AdminAuthority::none()
)));
assert!(expressible_in_v0_1(&entry(
VtcRole::Issuer,
AdminAuthority::for_role(AdminRole::CredentialOfficer)
)));
assert!(!expressible_in_v0_1(&entry(
VtcRole::Member,
AdminAuthority::for_role(AdminRole::Auditor)
)));
let mut narrow = AdminAuthority::community_admin();
narrow.capabilities =
CapabilityScope::listed(vec![CapRef::all(Capability::AuditRead).into()]).unwrap();
assert!(!expressible_in_v0_1(&entry(VtcRole::Admin, narrow)));
}
#[test]
fn a_0_1_rendering_has_canonical_names_and_no_scopes() {
let json = serde_json::to_value(AclEntryResponse::v0_1(entry(
VtcRole::Admin,
AdminAuthority::community_admin(),
)))
.unwrap();
assert_eq!(json["subject"], "did:key:zABC");
assert_eq!(json["role"], "admin");
assert_eq!(json["scopes"], json!([]));
assert_eq!(json["createdAt"], "2023-11-14T22:13:20+00:00");
assert_eq!(json["expiresAt"], "2027-01-15T08:00:00+00:00");
for old in ["did", "allowed_contexts", "created_at", "expires_at"] {
assert!(json.get(old).is_none(), "{old} should not be emitted");
}
}
#[test]
fn a_0_2_rendering_conforms_and_states_every_axis() {
use trust_tasks_rs::specs::acl::show::v0_2 as show;
let mut admin = AdminAuthority::for_role(AdminRole::RepoManager);
admin.capabilities = CapabilityScope::listed(vec![
"git.repo.manage@git-ns:github.com/acme"
.parse::<CapRef>()
.unwrap()
.into(),
])
.unwrap();
let mut e = entry(VtcRole::Member, admin);
e.delegated_by = Some("did:key:zGranter".into());
let review = DelegationReview {
subject: e.did.clone(),
granter: "did:key:zGranter".into(),
raised_at: 1,
deadline: 2,
};
let v = render_v0_2(&e, Some(&review));
assert_eq!(v["role"], "repo-manager");
assert_eq!(v["act"], json!({"scope": "all"}));
assert_eq!(v["keys"], json!({"scope": "none"}));
assert_eq!(v["approve"], json!({"scope": "all"}));
assert_eq!(v["delegatedBy"], "did:key:zGranter");
assert_eq!(v["ext"]["org.openvtc"]["communityRole"], "member");
assert_eq!(
v["ext"]["org.openvtc"]["delegationReview"]["granter"],
"did:key:zGranter"
);
let _: show::Response = conform(json!({ "entry": v })).expect("conforms");
let plain = render_v0_2(&entry(VtcRole::Member, AdminAuthority::none()), None);
assert_eq!(plain["role"], "member");
let _: show::Response = conform(json!({ "entry": plain })).expect("conforms");
}
#[test]
fn resource_grants_render_in_ext_without_their_reason() {
use trust_tasks_rs::specs::acl::show::v0_2 as show;
let mut e = entry(VtcRole::Member, AdminAuthority::none());
e.resource_grants
.push(crate::acl::resource_grant::ResourceGrant {
capability: Capability::GitRepoManage,
resource: "git-repo:github.com/acme/repo_1".parse().unwrap(),
grade: Some(crate::acl::resource_grant::RepoGrade::Own),
delegated_by: "did:key:zGranter".into(),
granted_at: "2026-10-01T00:00:00Z".parse().unwrap(),
expires_at: None,
reason: Some("private reason".into()),
subject_was_member: true,
granter_was_member: true,
break_glass: None,
review: None,
single_admin: None,
});
let v = render_v0_2(&e, None);
let g = &v["ext"]["org.openvtc"]["resourceGrants"][0];
assert_eq!(g["capability"], "git.repo.manage");
assert_eq!(g["resource"], "git-repo:github.com/acme/repo_1");
assert_eq!(g["grade"], "own");
assert_eq!(g["delegatedBy"], "did:key:zGranter");
assert!(!v.to_string().contains("private reason"));
let _: show::Response = conform(json!({ "entry": v })).expect("conforms");
}
#[test]
fn a_0_2_entry_parses_onto_the_model() {
let e = entry_from_v0_2(&json!({
"subject": "did:key:zS",
"role": "vetting-lead",
"act": {"scope": "all"},
"keys": {"scope": "none"},
"capabilities": {"scope": "listed", "grants": [
{"capability": "vtc.vetting.manage", "resource": "criterion:age-over-18"}
]},
}))
.ok()
.unwrap();
assert_eq!(e.admin.admin_role, Some(AdminRole::VettingLead));
assert_eq!(e.admin.approve, VtcActScope::None, "absent approve is none");
assert_eq!(e.role, VtcRole::Member);
let custom = entry_from_v0_2(&json!({
"subject": "did:key:zS", "role": "godmode",
"act": {"scope": "all"}, "keys": {"scope": "none"},
"capabilities": {"scope": "ceiling"},
}))
.ok()
.unwrap();
assert_eq!(
custom.admin.admin_role,
Some(AdminRole::Custom("godmode".into()))
);
assert!(matches!(
custom.admin.validate_against_ceiling(),
Err(crate::acl::capability::CeilingError::RoleNotRecognized(_))
));
assert!(matches!(
entry_from_v0_2(&json!({
"subject": "did:key:zS", "role": "Not A Role",
"act": {"scope": "all"}, "keys": {"scope": "none"},
"capabilities": {"scope": "ceiling"},
})),
Err(EntryParseError::RoleNotRecognized(_))
));
assert!(matches!(
entry_from_v0_2(&json!({
"subject": "did:key:zS", "role": "auditor",
"act": {"scope": "contexts", "contexts": ["a"]}, "keys": {"scope": "none"},
"capabilities": {"scope": "ceiling"},
})),
Err(EntryParseError::InvalidActScope("act", _))
));
assert!(matches!(
entry_from_v0_2(&json!({
"subject": "did:key:zS", "role": "auditor",
"act": {"scope": "all"}, "keys": {"scope": "none"},
"capabilities": {"scope": "listed", "grants": [{"capability": "vtc.everything"}]},
})),
Err(EntryParseError::UnknownCapability(_))
));
}
}