use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use vti_common::audit::{AclChangeData, AuditEvent};
use vti_common::error::AppError;
use super::{AdminAuthority, VtcAclEntry, get_acl_entry, list_acl_entries, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::server::AppState;
const REVIEW_PREFIX: &str = "delegation-review:";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DelegationReview {
pub subject: String,
pub granter: String,
pub raised_at: u64,
pub deadline: u64,
}
fn key(subject: &str) -> String {
format!("{REVIEW_PREFIX}{subject}")
}
pub async fn review_for(
state: &AppState,
subject: &str,
) -> Result<Option<DelegationReview>, AppError> {
state.admin_actions_ks.get(key(subject)).await
}
pub async fn list(state: &AppState) -> Result<Vec<DelegationReview>, AppError> {
let mut out = Vec::new();
for (_, v) in state
.admin_actions_ks
.prefix_iter_raw(REVIEW_PREFIX.as_bytes().to_vec())
.await?
{
match serde_json::from_slice::<DelegationReview>(&v) {
Ok(r) => out.push(r),
Err(e) => warn!(error = %e, "delegation review: unreadable record"),
}
}
Ok(out)
}
pub async fn clear(state: &AppState, subject: &str) -> Result<(), AppError> {
state.admin_actions_ks.remove(key(subject)).await
}
pub async fn on_granter_changed(
state: &AppState,
granter: &str,
after: Option<&VtcAclEntry>,
) -> Result<usize, AppError> {
let now = now_epoch();
let lifetime =
crate::admin_actions::setting(state, crate::config_store::ACTION_LIFETIME).await?;
let mut raised = Vec::new();
for e in list_acl_entries(&state.acl_ks).await? {
if e.delegated_by.as_deref() != Some(granter)
|| e.is_expired(now)
|| !e.admin.is_administrator()
{
continue;
}
if after.is_some_and(|a| super::granting::covers_entry(a, &e, now)) {
continue;
}
if review_for(state, &e.did).await?.is_some() {
continue;
}
let review = DelegationReview {
subject: e.did.clone(),
granter: granter.to_string(),
raised_at: now,
deadline: now.saturating_add(lifetime),
};
state.admin_actions_ks.insert(key(&e.did), &review).await?;
warn!(
subject = %e.did,
granter,
deadline = review.deadline,
"a delegated ACL entry's granter no longer covers it: under review, and withdrawn \
unless re-affirmed (VTI-ACL-071)"
);
raised.push(e.did.clone());
}
if let Err(e) = crate::admin_actions::raise_grants_review(
state,
granter,
&raised,
now.saturating_add(lifetime),
)
.await
{
warn!(granter, error = %e, "the grants review could not be raised; the sweeper still withdraws at the deadline");
}
Ok(raised.len())
}
pub async fn repoint(state: &AppState, old: &str, new: &str) -> Result<u32, AppError> {
let mut repointed = 0;
let _git = crate::git_ns::store::write_lock().await;
if get_acl_entry(&state.acl_ks, new)
.await?
.is_some_and(|e| !e.resource_grants.is_empty())
{
super::resource_grant::index_holder(&state.acl_ks, new, true).await?;
}
super::resource_grant::index_holder(&state.acl_ks, old, false).await?;
for mut e in list_acl_entries(&state.acl_ks).await? {
let mut changed = false;
if e.delegated_by.as_deref() == Some(old) {
e.delegated_by = Some(new.to_string());
changed = true;
repointed += 1;
}
for g in e.resource_grants.iter_mut() {
if g.delegated_by == old {
g.delegated_by = new.to_string();
changed = true;
}
if let Some(r) = g.review.as_mut()
&& r.granter == old
{
r.granter = new.to_string();
changed = true;
}
}
if changed {
store_acl_entry(&state.acl_ks, &e).await?;
}
}
drop(_git);
for mut review in list(state).await? {
let moved = review.subject == old;
if review.granter == old {
review.granter = new.to_string();
}
if moved {
review.subject = new.to_string();
let mut batch = vti_common::store::Batch::new();
batch
.remove(key(old))
.insert(key(&review.subject), &review)?;
state.admin_actions_ks.apply_batch(batch).await?;
} else if review.granter == new {
state
.admin_actions_ks
.insert(key(&review.subject), &review)
.await?;
}
}
Ok(repointed)
}
pub async fn reaffirm(
state: &AppState,
granter: &str,
subjects: &[String],
approvers: &[String],
) -> Result<serde_json::Value, AppError> {
let now = now_epoch();
let mut deciders = Vec::new();
for d in approvers {
if let Some(e) = get_acl_entry(&state.acl_ks, d).await? {
deciders.push(e);
}
}
let (mut reaffirmed, mut uncovered) = (Vec::new(), Vec::new());
for subject in subjects {
let Some(mut entry) = get_acl_entry(&state.acl_ks, subject).await? else {
continue;
};
if entry.delegated_by.as_deref() != Some(granter)
|| review_for(state, subject).await?.is_none()
{
continue;
}
let Some(by) = deciders
.iter()
.find(|d| super::granting::covers_entry(d, &entry, now))
else {
uncovered.push(subject.clone());
continue;
};
entry.delegated_by = Some(by.did.clone());
entry.updated_at = Some(now);
entry.updated_by = Some(by.did.clone());
store_acl_entry(&state.acl_ks, &entry).await?;
clear(state, subject).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&by.did,
Some(subject),
AuditEvent::AclUpdated(AclChangeData {
did: entry.did.clone(),
role: entry
.admin
.admin_role
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| entry.role.to_string()),
contexts: entry.capability_list(),
expires_at: entry.expires_at.map(|e| e.to_string()),
}),
)
.await?;
}
info!(subject, granter, by = %by.did, "a departed granter's grant was re-affirmed");
reaffirmed.push(subject.clone());
}
Ok(serde_json::json!({ "reaffirmed": reaffirmed, "notCovered": uncovered }))
}
pub async fn withdraw_reviewed(
state: &AppState,
granter: &str,
subjects: &[String],
) -> Result<usize, AppError> {
let now = now_epoch();
let mut withdrawn = 0;
for subject in subjects {
let Some(review) = review_for(state, subject).await? else {
continue;
};
if review.granter != granter {
continue;
}
let Some(mut entry) = get_acl_entry(&state.acl_ks, subject).await? else {
clear(state, subject).await?;
continue;
};
if entry.delegated_by.as_deref() != Some(granter) {
clear(state, subject).await?;
continue;
}
if super::admin_consent::is_live_role_assigner(&entry, now) {
let _guard = crate::ceremony::lock_admin_set().await;
if super::admin_consent::check_attrition(state, &entry.did)
.await
.is_err()
{
warn!(
subject,
"a declined review's grant is the last holder of vtc.roles.assign: kept"
);
continue;
}
withdraw(state, &mut entry, now).await?;
} else {
withdraw(state, &mut entry, now).await?;
}
clear(state, subject).await?;
withdrawn += 1;
}
Ok(withdrawn)
}
pub async fn sweep(state: &AppState) -> Result<usize, AppError> {
let now = now_epoch();
let entries = list_acl_entries(&state.acl_ks).await?;
let mut departed: Vec<String> = entries
.iter()
.filter(|e| !e.is_expired(now) && e.admin.is_administrator())
.filter_map(|e| e.delegated_by.clone())
.filter(|g| {
entries
.iter()
.find(|x| &x.did == g)
.is_none_or(|x| x.is_expired(now))
})
.collect();
departed.sort();
departed.dedup();
for granter in departed {
on_granter_changed(state, &granter, None).await?;
}
let mut withdrawn = 0;
for review in list(state).await? {
if review.deadline > now {
continue;
}
let Some(mut entry) = get_acl_entry(&state.acl_ks, &review.subject).await? else {
clear(state, &review.subject).await?;
continue;
};
if entry.delegated_by.as_deref() != Some(review.granter.as_str()) {
clear(state, &review.subject).await?;
continue;
}
if super::admin_consent::is_live_role_assigner(&entry, now) {
let _guard = crate::ceremony::lock_admin_set().await;
if super::admin_consent::check_attrition(state, &entry.did)
.await
.is_err()
{
warn!(
subject = %entry.did,
"a delegated entry under review is the last holder of vtc.roles.assign: \
not withdrawn"
);
continue;
}
withdraw(state, &mut entry, now).await?;
} else {
withdraw(state, &mut entry, now).await?;
}
clear(state, &review.subject).await?;
withdrawn += 1;
}
Ok(withdrawn)
}
async fn withdraw(state: &AppState, entry: &mut VtcAclEntry, now: u64) -> Result<(), AppError> {
let granter = entry.delegated_by.clone().unwrap_or_default();
entry.admin = AdminAuthority::none();
entry.delegated_by = None;
entry.updated_at = Some(now);
entry.updated_by = Some("vtc:delegation-review".into());
store_acl_entry(&state.acl_ks, entry).await?;
let _ = crate::routes::auth::revoke_sessions_for_did(&state.sessions_ks, &entry.did).await;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
"vtc:delegation-review",
Some(&entry.did),
AuditEvent::AclUpdated(AclChangeData {
did: entry.did.clone(),
role: entry.role.to_string(),
contexts: Vec::new(),
expires_at: entry.expires_at.map(|e| e.to_string()),
}),
)
.await?;
}
info!(
subject = %entry.did,
granter,
"delegated ACL entry withdrawn: its granter left and nobody re-affirmed it"
);
let did = entry.did.clone();
on_granter_changed(state, &did, Some(&*entry)).await?;
Ok(())
}