use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::warn;
use trust_tasks_rs::specs::task_consent::decision::v0_1 as decision;
use trust_tasks_rs::specs::task_consent::decision::v0_2 as decision_v0_2;
use trust_tasks_rs::specs::task_consent::request::v0_1 as request;
use vti_common::audit::AuditEvent;
use vti_common::error::AppError;
use vti_common::task_consent::{self, effects::StatePin};
use super::{CapRef, Capability, ResourceQualifier, VtcAclEntry, get_acl_entry, list_acl_entries};
use crate::auth::session::now_epoch;
use crate::config_store::{ConfigStore, UNRESTRICTED_ADMIN_CONSENT_THRESHOLD};
use crate::server::AppState;
pub const APPROVER_SET: &str = "capability-holders";
pub(crate) const REQUEST_TYPE: &str = <request::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const DECISION_TYPE: &str = <decision::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const DECISION_V0_2_TYPE: &str =
<decision_v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const APPROVER_SET_EXCEPT_SUBJECT: &str = "capability-holders-except-subject";
const STATE_DOMAIN: &[u8] = b"vtc/acl-entry-state/v1\0";
const SETTING_STATE_DOMAIN: &[u8] = b"vtc/consent-setting-state/v1\0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Act {
GrantUnrestricted,
ReduceUnrestricted,
LowerThreshold,
ChangeAuthorityPolicy(crate::policy::PolicyPurpose),
OperatorWrite,
ChangeRoles,
RestoreBackup,
GrantsReview,
BreakGlassReview,
JoinReview,
VettingReview,
DirectAdmission,
ChangeStorage,
ChangeTrustedWitnesses,
ChangeJoinCriteria,
ChangeHiddenVetting,
ChangeDirectoryPrivacy,
ChangeRecognitionPolicy(crate::policy::PolicyPurpose),
}
impl Act {
pub(crate) fn requirement(self) -> &'static str {
match self {
Self::GrantUnrestricted => "VTI-APV-018",
Self::ReduceUnrestricted => "VTI-APV-019",
Self::LowerThreshold => "VTI-APV-020",
Self::ChangeAuthorityPolicy(_) => "VTI-VTC-022",
Self::OperatorWrite => "VTI-VTC-023",
Self::ChangeRoles | Self::RestoreBackup => "VTI-APV-018",
Self::GrantsReview => "VTI-ACL-071",
Self::BreakGlassReview => "git-ns/right/ratify",
Self::JoinReview => "vtc/join-requests/decide",
Self::VettingReview => "vtc/vetting/revocations",
Self::DirectAdmission => "the admission policy",
Self::ChangeStorage => "the storage tasks' consent",
Self::ChangeTrustedWitnesses => "VTI-CMP-090",
Self::ChangeDirectoryPrivacy => "VTI-DIR-024",
Self::ChangeJoinCriteria
| Self::ChangeHiddenVetting
| Self::ChangeRecognitionPolicy(_) => "VTI-APV-023",
}
}
pub(crate) fn approver_set(self) -> &'static str {
match self {
Self::ReduceUnrestricted | Self::GrantsReview => APPROVER_SET_EXCEPT_SUBJECT,
Self::OperatorWrite => "administrators",
Self::BreakGlassReview => "namespace-administrators-except-subject",
Self::JoinReview => "join-deciders",
Self::VettingReview => "vetting-managers-except-subject",
Self::DirectAdmission => "member-managers-except-subject",
_ => APPROVER_SET,
}
}
pub(crate) fn excludes_subject(self) -> bool {
matches!(
self,
Self::ReduceUnrestricted
| Self::GrantsReview
| Self::BreakGlassReview
| Self::JoinReview
| Self::VettingReview
| Self::DirectAdmission
)
}
pub(crate) fn is_queue(self) -> bool {
matches!(
self,
Self::BreakGlassReview | Self::JoinReview | Self::VettingReview
)
}
pub(crate) fn default_stake(self) -> Vec<CapRef> {
match self {
Self::GrantUnrestricted
| Self::ReduceUnrestricted
| Self::LowerThreshold
| Self::OperatorWrite
| Self::GrantsReview => vec![CapRef::all(Capability::RolesAssign)],
Self::ChangeAuthorityPolicy(purpose) => vec![CapRef::new(
Capability::PolicyAdmin,
Some(ResourceQualifier::Policy(purpose)),
)],
Self::ChangeRoles => vec![
CapRef::all(Capability::RolesAssign),
CapRef::all(Capability::ApprovalsAdmin),
],
Self::RestoreBackup => vec![CapRef::all(Capability::BackupRestore)],
Self::BreakGlassReview => vec![CapRef::all(Capability::GitNsAdmin)],
Self::JoinReview => vec![CapRef::all(Capability::JoinDecide)],
Self::VettingReview => vec![CapRef::all(Capability::VettingManage)],
Self::DirectAdmission => vec![CapRef::all(Capability::MembersManage)],
Self::ChangeStorage => vec![CapRef::all(Capability::RoomsAdmin)],
Self::ChangeTrustedWitnesses => vec![CapRef::all(Capability::ConfigAdmin)],
Self::ChangeJoinCriteria => vec![CapRef::all(Capability::SurfaceAdmin)],
Self::ChangeHiddenVetting => vec![CapRef::all(Capability::VettingManage)],
Self::ChangeDirectoryPrivacy => vec![CapRef::all(Capability::DirectoryAdmin)],
Self::ChangeRecognitionPolicy(purpose) => vec![CapRef::new(
Capability::PolicyAdmin,
Some(ResourceQualifier::Policy(purpose)),
)],
}
}
pub(crate) fn stake_or_default(self, stake: &[CapRef]) -> Vec<CapRef> {
if stake.is_empty() {
self.default_stake()
} else {
stake.to_vec()
}
}
pub(crate) fn kind(self, type_uri: &str) -> &'static str {
use crate::admin_actions::summary as s;
match self {
Self::GrantUnrestricted
if type_uri == crate::trust_tasks::admin_tasks::INVITES_CREATE_TYPE =>
{
s::KIND_INVITE_CREATE
}
Self::GrantUnrestricted => s::KIND_GRANT_AUTHORITY,
Self::ReduceUnrestricted => s::KIND_REDUCE_AUTHORITY,
Self::LowerThreshold => s::KIND_THRESHOLD_LOWER,
Self::ChangeAuthorityPolicy(_) => s::KIND_POLICY_AUTHORITY,
Self::OperatorWrite => s::KIND_OPERATOR_WRITE,
Self::ChangeRoles if type_uri == crate::trust_tasks::role_tasks::DELETE_TYPE => {
s::KIND_ROLE_DELETE
}
Self::ChangeRoles => s::KIND_ROLE_DEFINE,
Self::RestoreBackup => s::KIND_BACKUP_RESTORE,
Self::GrantsReview => s::KIND_GRANTS_REVIEW,
Self::BreakGlassReview => s::KIND_BREAK_GLASS_REVIEW,
Self::JoinReview => s::KIND_JOIN_REVIEW,
Self::VettingReview => s::KIND_VETTING_REVIEW,
Self::DirectAdmission
if type_uri == crate::trust_tasks::admission_tasks::MAKE_MEMBERSHIP_TYPE =>
{
s::KIND_MEMBER_MAKE_MEMBERSHIP
}
Self::DirectAdmission => s::KIND_MEMBER_ADMIT,
Self::ChangeStorage => match type_uri {
crate::trust_tasks::storage_tasks::CREATE_TYPE => s::KIND_STORAGE_CONFIG_CREATE,
crate::trust_tasks::storage_tasks::RETIRE_TYPE => s::KIND_STORAGE_CONFIG_RETIRE,
crate::trust_tasks::storage_tasks::ASSIGN_TYPE => s::KIND_ROOM_STORAGE_ASSIGN,
_ => s::KIND_STORAGE_CONFIG_UPDATE,
},
Self::ChangeTrustedWitnesses => s::KIND_WITNESSES_CHANGE,
Self::ChangeJoinCriteria => s::KIND_CRITERIA_CHANGE,
Self::ChangeHiddenVetting => s::KIND_HIDDEN_VETTING_CHANGE,
Self::ChangeDirectoryPrivacy => s::KIND_DIRECTORY_FIELD_WIDEN,
Self::ChangeRecognitionPolicy(_) => s::KIND_POLICY_RECOGNITION,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Operation<'a> {
pub type_uri: &'a str,
pub payload: &'a Value,
}
#[must_use]
pub fn newly_conferred(prev: Option<&VtcAclEntry>, next: &VtcAclEntry, now: u64) -> Vec<CapRef> {
let held: Vec<CapRef> = prev
.filter(|p| !p.is_expired(now))
.map(|p| p.admin.conferring())
.unwrap_or_default();
next.admin
.conferring()
.into_iter()
.filter(|c| !held.iter().any(|h| h.covers(c)))
.collect()
}
#[must_use]
pub fn lost_conferring(prev: &VtcAclEntry, next: Option<&VtcAclEntry>, now: u64) -> Vec<CapRef> {
if prev.is_expired(now) {
return vec![];
}
let shortened = next.is_some_and(|n| match (prev.expires_at, n.expires_at) {
(None, Some(_)) => true,
(Some(was), Some(now)) => now < was,
_ => false,
});
let kept: Vec<CapRef> = next
.filter(|_| !shortened)
.map(|n| n.admin.conferring())
.unwrap_or_default();
prev.admin
.conferring()
.into_iter()
.filter(|c| !kept.iter().any(|k| k.covers(c)))
.collect()
}
#[must_use]
pub fn may_approve(entry: &VtcAclEntry, stake: &[CapRef], now: u64) -> bool {
!entry.is_expired(now) && stake.iter().all(|c| entry.can_approve(c))
}
#[must_use]
pub fn may_decide(entry: &VtcAclEntry, act: Act, stake: &[CapRef], now: u64) -> bool {
if !act.is_queue() {
return may_approve(entry, stake, now);
}
if entry.is_expired(now) {
return false;
}
match act {
Act::VettingReview => entry.can_any(Capability::VettingManage),
_ => stake.iter().all(|c| entry.holds(c)),
}
}
pub async fn role_assigners(state: &AppState, now: u64) -> Result<Vec<String>, AppError> {
Ok(list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(|e| is_live_role_assigner(e, now) && !e.is_suspended())
.map(|e| e.did)
.collect())
}
pub async fn threshold(state: &AppState) -> Result<u64, AppError> {
let fallback = state
.config
.read()
.await
.acl
.unrestricted_admin_consent_threshold;
crate::config_store::live_consent_threshold(
fallback,
&ConfigStore::new(state.config_ks.clone()),
)
.await
}
pub async fn check_threshold_meetable(state: &AppState, threshold: u64) -> Result<(), AppError> {
if threshold <= 1 {
return Ok(());
}
let admins = role_assigners(state, now_epoch()).await?.len() as u64;
if threshold > admins.saturating_sub(1) {
return Err(AppError::Validation(format!(
"{UNRESTRICTED_ADMIN_CONSENT_THRESHOLD} = {threshold} could never be met: a grant of \
an authority-conferring capability would need {threshold} holders of \
vtc.roles.assign besides the one asking, and this community has {admins} in total. \
Grant more community administrators first, or set it to at most {}",
admins.saturating_sub(1).max(1)
)));
}
Ok(())
}
#[must_use]
pub fn is_live_role_assigner(entry: &VtcAclEntry, now: u64) -> bool {
!entry.is_expired(now) && entry.admin.can(Capability::RolesAssign, None)
}
pub async fn check_attrition(state: &AppState, subject: &str) -> Result<(), AppError> {
let remaining = role_assigners(state, now_epoch())
.await?
.into_iter()
.filter(|d| d != subject)
.count() as u64;
if remaining == 0 {
return Err(AppError::Conflict(format!(
"refusing to end the last administrator holding vtc.roles.assign ({subject}): nobody would be \
left who could grant or consent to authority (VTI-APV-009). Make another community \
administrator first"
)));
}
let threshold = threshold(state).await?;
if threshold > 1 && threshold > remaining.saturating_sub(1) {
return Err(AppError::Conflict(format!(
"refusing to end {subject}'s vtc.roles.assign: {remaining} holder(s) would remain, so \
{UNRESTRICTED_ADMIN_CONSENT_THRESHOLD} = {threshold} could never be met again \
(VTI-APV-009). Lower it first — config/patch \
{{\"{UNRESTRICTED_ADMIN_CONSENT_THRESHOLD}\": {}}} — then retry",
remaining.saturating_sub(1).max(1)
)));
}
Ok(())
}
#[derive(Debug)]
#[must_use = "a ReadyGrant authorizes nothing until it is spent with the write"]
pub struct ReadyGrant {
ready: Ready,
}
#[derive(Debug)]
enum Ready {
Approved { action_id: String },
Waived(Box<crate::admin_actions::Waiver>),
Immediate(Box<crate::admin_actions::Immediate>),
}
impl ReadyGrant {
pub(crate) fn approved(action_id: String) -> Self {
Self {
ready: Ready::Approved { action_id },
}
}
pub fn is_waived(&self) -> bool {
matches!(self.ready, Ready::Waived(_))
}
pub async fn spend(self, state: &AppState) -> Result<(), AppError> {
match self.ready {
Ready::Approved { action_id } => {
crate::admin_actions::note_gate_spent(&action_id);
Ok(())
}
Ready::Waived(waiver) => crate::admin_actions::spend_waiver(state, *waiver).await,
Ready::Immediate(imm) => crate::admin_actions::spend_immediate(state, *imm).await,
}
}
}
pub async fn single_admin_mode(state: &AppState) -> bool {
state.config.read().await.acl.single_admin_mode
}
#[derive(Debug)]
pub enum SignedGate {
Ready(ReadyGrant),
StepUpRequired(Box<crate::acl::bound_step_up::ApproveRequest>),
}
pub async fn gesture_then_consent(
state: &AppState,
requester: &str,
subject: &str,
stake: &[CapRef],
op: Operation<'_>,
gesture_reason: &str,
consent_summary: &str,
) -> Result<SignedGate, AppError> {
gesture_then_consent_for(
state,
Act::GrantUnrestricted,
stake,
requester,
subject,
op,
gesture_reason,
consent_summary,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn gesture_then_consent_for(
state: &AppState,
act: Act,
stake: &[CapRef],
requester: &str,
subject: &str,
op: Operation<'_>,
gesture_reason: &str,
consent_summary: &str,
) -> Result<SignedGate, AppError> {
use super::bound_step_up::{self, EvidencedGate};
let stake = act.stake_or_default(stake);
if let Some(exec) = crate::admin_actions::executing() {
let action_id =
crate::admin_actions::recheck(state, &exec, act, requester, subject, op).await?;
return Ok(SignedGate::Ready(ReadyGrant::approved(action_id)));
}
if let Some(open) = crate::admin_actions::open_for(state, requester, op).await? {
return Err(crate::admin_actions::parked_error(state, &open).await);
}
let now = now_epoch();
let approvers = approvers_for(state, act, &stake, requester, subject, now).await?;
let threshold = threshold(state).await?;
let waive = single_admin_mode(state).await;
if !waive {
refuse_if_unmeetable(
act,
&stake,
approvers.len() as u64,
threshold,
consent_summary,
)?;
crate::admin_actions::check_limits(state, act, requester, subject).await?;
}
let evidence = match bound_step_up::redeem_or_request_with_evidence(
state,
requester,
op.type_uri,
op.payload,
gesture_reason,
)
.await?
{
EvidencedGate::Required(request) => return Ok(SignedGate::StepUpRequired(request)),
EvidencedGate::Satisfied(evidence) => evidence,
};
if waive {
return Ok(SignedGate::Ready(ReadyGrant {
ready: Ready::Waived(Box::new(crate::admin_actions::Waiver::new(
act,
stake,
requester,
subject,
op,
consent_summary,
evidence,
)?)),
}));
}
let pin = pin_for(state, act, subject).await?;
let action = crate::admin_actions::park(
state,
crate::admin_actions::Parking {
act,
stake,
requester,
subject,
op,
summary: consent_summary,
evidence,
approvers,
threshold,
pin,
cooling_off: None,
},
)
.await?;
Err(crate::admin_actions::parked_error(state, &action).await)
}
#[derive(Debug)]
#[must_use = "a consented reduction must spend its grant, and an unopposed one must be recorded"]
pub enum Reduction {
StepUpOnly,
Consented(ReadyGrant),
Unopposed(Option<ReadyGrant>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agreement {
Consented,
StepUpOnly,
Unopposed,
}
impl Agreement {
pub fn wire(self) -> &'static str {
match self {
Self::Consented => "consented",
Self::StepUpOnly | Self::Unopposed => "unopposed",
}
}
}
impl Reduction {
pub async fn spend(self, state: &AppState) -> Result<Agreement, AppError> {
match self {
Self::StepUpOnly => Ok(Agreement::StepUpOnly),
Self::Consented(ready) => ready.spend(state).await.map(|()| Agreement::Consented),
Self::Unopposed(ready) => {
if let Some(ready) = ready {
ready.spend(state).await?;
}
Ok(Agreement::Unopposed)
}
}
}
}
#[derive(Debug)]
pub enum ReductionGate {
Cleared(Reduction),
StepUpRequired(Box<crate::acl::bound_step_up::ApproveRequest>),
}
pub async fn gate_reduction(
state: &AppState,
requester: &str,
subject: &VtcAclEntry,
after: Option<&VtcAclEntry>,
op: Operation<'_>,
gesture_reason: &str,
consent_summary: &str,
) -> Result<ReductionGate, AppError> {
use super::bound_step_up::{self, EvidencedGate, Gate};
let now = now_epoch();
let lost = if subject.did == requester {
vec![]
} else {
lost_conferring(subject, after, now)
};
let executing = crate::admin_actions::executing();
let immediate = crate::admin_actions::immediate_request(op.payload)?;
if immediate.is_some() && executing.is_none() && !single_admin_mode(state).await {
return Err(AppError::Forbidden(format!(
"removing or reducing an administrator now, without its cooling-off \
(ext.org.openvtc.immediate), is offered only in single-administrator mode \
(VTI-APV-022), which is set on the host ([acl] single_admin_mode in config.toml) and \
cannot be changed through this service. Without it the reduction of {} waits out \
its cooling-off ({}), during which they are suspended — send it again without \
`immediate`",
subject.did,
crate::config_store::REMOVAL_COOLING_OFF
)));
}
if !lost.is_empty() {
if is_live_role_assigner(subject, now)
&& !after.is_some_and(|a| is_live_role_assigner(a, now))
{
check_attrition(state, &subject.did).await?;
}
if let Some(exec) = executing.as_ref().filter(|e| e.cooling_off) {
let action_id =
crate::admin_actions::recheck_cooling_off(state, exec, requester, &subject.did, op)
.await?;
return Ok(ReductionGate::Cleared(Reduction::Unopposed(Some(
ReadyGrant::approved(action_id),
))));
}
let third = if single_admin_mode(state).await {
0
} else {
approvers_for(
state,
Act::ReduceUnrestricted,
&lost,
requester,
&subject.did,
now,
)
.await?
.len()
};
if executing.is_none() && third == 0 {
crate::admin_actions::refuse_if_reduced_first(state, requester, &subject.did).await?;
}
if let Some(imm) = immediate.as_ref()
&& executing.is_none()
{
return immediate_reduction(
state,
requester,
subject,
op,
imm,
lost,
gesture_reason,
consent_summary,
)
.await;
}
if third > 0 || executing.is_some() {
return Ok(
match gesture_then_consent_for(
state,
Act::ReduceUnrestricted,
&lost,
requester,
&subject.did,
op,
gesture_reason,
consent_summary,
)
.await?
{
SignedGate::Ready(ready) => ReductionGate::Cleared(Reduction::Consented(ready)),
SignedGate::StepUpRequired(r) => ReductionGate::StepUpRequired(r),
},
);
}
let window = crate::config_store::live_action_setting(
crate::config_store::REMOVAL_COOLING_OFF,
&state.config.read().await.clone(),
&ConfigStore::new(state.config_ks.clone()),
)
.await?;
if window > 0 {
if let Some(open) = crate::admin_actions::open_for(state, requester, op).await? {
return Err(crate::admin_actions::parked_error(state, &open).await);
}
if let Some(other) =
crate::admin_actions::open_cooling_off_on(state, &subject.did).await?
{
return Err(AppError::Conflict(format!(
"a reduction of {} is already cooling off ({}, requested by {}, lands {}), and \
they are suspended until it lands or is cancelled. Cancel that one first \
(vtc/admin/actions/cancel) to ask for something different",
subject.did,
other.id,
other.requester,
crate::admin_actions::rfc3339(other.cooling_off_until.unwrap_or_default()),
)));
}
if is_live_role_assigner(subject, now)
&& after.is_some_and(|a| is_live_role_assigner(a, now))
{
check_attrition(state, &subject.did).await?;
}
crate::admin_actions::check_limits(
state,
Act::ReduceUnrestricted,
requester,
&subject.did,
)
.await?;
let evidence = match bound_step_up::redeem_or_request_with_evidence(
state,
requester,
op.type_uri,
op.payload,
gesture_reason,
)
.await?
{
EvidencedGate::Required(request) => {
return Ok(ReductionGate::StepUpRequired(request));
}
EvidencedGate::Satisfied(evidence) => evidence,
};
warn!(
requester,
subject = %subject.did,
task = op.type_uri,
window,
"ending an unrestricted admin's authority with nobody else left to consent \
(VTI-APV-019): parked for its cooling-off, the subject suspended until it lands"
);
let pin = pin_for(state, Act::ReduceUnrestricted, &subject.did).await?;
let action = crate::admin_actions::park(
state,
crate::admin_actions::Parking {
act: Act::ReduceUnrestricted,
stake: lost.clone(),
requester,
subject: &subject.did,
op,
summary: consent_summary,
evidence,
approvers: Vec::new(),
threshold: 0,
pin,
cooling_off: Some(window),
},
)
.await?;
return Err(crate::admin_actions::parked_error(state, &action).await);
}
}
Ok(
match bound_step_up::redeem_or_request(
state,
requester,
op.type_uri,
op.payload,
gesture_reason,
)
.await?
{
Gate::Satisfied if !lost.is_empty() => {
warn!(
requester,
subject = %subject.did,
task = op.type_uri,
"taking authority-conferring capabilities away with nobody else left to \
consent (VTI-APV-019), with no cooling-off configured: the requester's \
step-up is the only gate"
);
ReductionGate::Cleared(Reduction::Unopposed(None))
}
Gate::Satisfied => ReductionGate::Cleared(Reduction::StepUpOnly),
Gate::Required(r) => ReductionGate::StepUpRequired(r),
},
)
}
#[allow(clippy::too_many_arguments)]
async fn immediate_reduction(
state: &AppState,
requester: &str,
subject: &VtcAclEntry,
op: Operation<'_>,
imm: &crate::admin_actions::ImmediateRequest,
lost: Vec<CapRef>,
gesture_reason: &str,
consent_summary: &str,
) -> Result<ReductionGate, AppError> {
use super::bound_step_up::{self, EvidencedGate};
let confirmed =
imm.confirm == subject.did || imm.action_id.as_deref().is_some_and(|id| imm.confirm == id);
if !confirmed {
return Err(AppError::Validation(format!(
"the confirmation does not match. To make this change now, without its cooling-off \
({consent_summary}), type the subject's DID ({}){} as \
ext.org.openvtc.immediate.confirm",
subject.did,
imm.action_id
.as_deref()
.map(|id| format!(" or the action's id ({id})"))
.unwrap_or_default(),
)));
}
let target = crate::admin_actions::cooling_off_to_land(
state,
&subject.did,
op,
imm.action_id.as_deref(),
)
.await?;
let evidence = match bound_step_up::redeem_or_request_with_evidence(
state,
requester,
op.type_uri,
op.payload,
&format!("{gesture_reason} — now, without the cooling-off"),
)
.await?
{
EvidencedGate::Required(request) => return Ok(ReductionGate::StepUpRequired(request)),
EvidencedGate::Satisfied(evidence) => evidence,
};
warn!(
requester,
subject = %subject.did,
task = op.type_uri,
landing = target.as_deref().unwrap_or("-"),
"reducing an administrator now, without the cooling-off — single-administrator mode \
(VTI-APV-022, VTI-APV-019)"
);
Ok(ReductionGate::Cleared(Reduction::Unopposed(Some(
ReadyGrant {
ready: Ready::Immediate(Box::new(crate::admin_actions::Immediate::new(
lost,
requester,
&subject.did,
op,
consent_summary,
evidence,
target,
)?)),
},
))))
}
pub async fn settle_reduction(
state: &AppState,
requester: &str,
prior: &VtcAclEntry,
after: Option<&VtcAclEntry>,
op: Operation<'_>,
gesture_reason: &str,
consent_summary: &str,
) -> Result<Option<Agreement>, crate::error::TaskError> {
if !prior.is_administrator() {
return Ok(None);
}
match gate_reduction(
state,
requester,
prior,
after,
op,
gesture_reason,
consent_summary,
)
.await?
{
ReductionGate::Cleared(reduction) => Ok(Some(reduction.spend(state).await?)),
ReductionGate::StepUpRequired(request) => Err(crate::error::TaskError::step_up(request)),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn after_reduction(
state: &AppState,
decided_by: &str,
prior: &VtcAclEntry,
after: Option<&VtcAclEntry>,
agreement: Agreement,
task: &str,
reason: Option<&str>,
notify: bool,
) -> Result<(), AppError> {
if agreement == Agreement::Unopposed {
record_unopposed_reduction(state, decided_by, prior, task).await?;
}
if notify {
crate::ceremony::authority_reduced_notice::send(
state,
prior,
after,
agreement,
decided_by,
chrono::Utc::now(),
reason,
)
.await;
}
Ok(())
}
pub async fn record_unopposed_reduction(
state: &AppState,
requester: &str,
prior: &VtcAclEntry,
task: &str,
) -> Result<(), AppError> {
let Some(writer) = state.audit_writer.as_ref() else {
return Ok(());
};
writer
.write(
requester,
Some(&prior.did),
AuditEvent::AuthorityReducedUnopposed(
vti_common::audit::AuthorityReducedUnopposedData {
task: task.to_string(),
prior_role: prior
.admin
.admin_role
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| prior.role.to_string()),
prior_scopes: prior
.admin
.effective()
.iter()
.map(CapRef::display)
.collect(),
},
),
)
.await?;
Ok(())
}
pub async fn require(
state: &AppState,
requester: &str,
subject: &str,
stake: &[CapRef],
op: Operation<'_>,
summary: &str,
) -> Result<ReadyGrant, AppError> {
if let Some(exec) = crate::admin_actions::executing() {
let action_id = crate::admin_actions::recheck(
state,
&exec,
Act::GrantUnrestricted,
requester,
subject,
op,
)
.await?;
return Ok(ReadyGrant::approved(action_id));
}
let stake = Act::GrantUnrestricted.stake_or_default(stake);
let approvers = approvers_for(
state,
Act::GrantUnrestricted,
&stake,
requester,
subject,
now_epoch(),
)
.await?
.len() as u64;
if single_admin_mode(state).await {
return Err(AppError::Forbidden(format!(
"{summary} needs, in single-administrator mode, your passkey gesture bound to the \
operation (VTI-APV-022), which only a signed {} document can carry",
op.type_uri
)));
}
refuse_if_unmeetable(
Act::GrantUnrestricted,
&stake,
approvers,
threshold(state).await?,
summary,
)?;
Err(AppError::Forbidden(format!(
"{summary} needs the approval of another holder of {} (VTI-APV-018), which only a \
signed {} document can wait for",
stake_list(&stake),
op.type_uri
)))
}
fn stake_list(stake: &[CapRef]) -> String {
stake
.iter()
.map(CapRef::display)
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn refuse_if_unmeetable(
act: Act,
stake: &[CapRef],
approvers: u64,
threshold: u64,
summary: &str,
) -> Result<(), AppError> {
if approvers < threshold {
let others = if act.excludes_subject() {
"holder(s) other than you and the subject"
} else {
"other holder(s)"
};
return Err(AppError::Forbidden(format!(
"{summary} needs consent from {threshold} {others} of {}, and this community has \
{approvers} ({}). Add another community administrator with the offline \
break-glass while the daemon is stopped — `vtc acl add --did <did> --admin-role \
community-admin` — and send this again",
stake_list(stake),
act.requirement()
)));
}
Ok(())
}
pub(crate) async fn approvers_for(
state: &AppState,
act: Act,
stake: &[CapRef],
requester: &str,
subject: &str,
now: u64,
) -> Result<Vec<String>, AppError> {
let stake = act.stake_or_default(stake);
Ok(list_acl_entries(&state.acl_ks)
.await?
.into_iter()
.filter(|e| may_decide(e, act, &stake, now))
.map(|e| e.did)
.filter(|d| d != requester && !(act.excludes_subject() && d == subject))
.collect())
}
pub(crate) fn requester_still_authorized(
entry: &VtcAclEntry,
act: Act,
stake: &[CapRef],
now: u64,
) -> bool {
if entry.is_expired(now) || !entry.admin.is_administrator() || entry.is_suspended() {
return false;
}
match act {
Act::GrantUnrestricted => {
entry.admin.can_any(Capability::RolesAssign)
&& act
.stake_or_default(stake)
.iter()
.all(|c| entry.admin.holds(c))
}
Act::ReduceUnrestricted => entry.admin.can_any(Capability::RolesAssign),
Act::LowerThreshold => entry.admin.can(Capability::ConfigAdmin, None),
Act::ChangeAuthorityPolicy(p) => entry
.admin
.can(Capability::PolicyAdmin, Some(&ResourceQualifier::Policy(p))),
Act::OperatorWrite => true,
Act::ChangeRoles => {
entry.admin.can(Capability::RolesAssign, None)
&& entry.admin.can(Capability::ApprovalsAdmin, None)
}
Act::RestoreBackup => entry.admin.can(Capability::BackupRestore, None),
Act::GrantsReview => true,
Act::BreakGlassReview | Act::JoinReview | Act::VettingReview => true,
Act::DirectAdmission => entry.admin.can(Capability::MembersManage, None),
Act::ChangeStorage => entry.admin.can(Capability::RoomsAdmin, None),
Act::ChangeTrustedWitnesses => entry.admin.can(Capability::ConfigAdmin, None),
Act::ChangeJoinCriteria => entry.admin.can(Capability::SurfaceAdmin, None),
Act::ChangeHiddenVetting => entry.admin.can(Capability::VettingManage, None),
Act::ChangeDirectoryPrivacy => entry.admin.can(Capability::DirectoryAdmin, None),
Act::ChangeRecognitionPolicy(p) => entry
.admin
.can(Capability::PolicyAdmin, Some(&ResourceQualifier::Policy(p))),
}
}
pub(crate) async fn pin_for(
state: &AppState,
act: Act,
subject: &str,
) -> Result<StatePin, AppError> {
let value = match act {
Act::GrantUnrestricted | Act::ReduceUnrestricted | Act::OperatorWrite => {
return state_pin(state, subject).await;
}
Act::LowerThreshold => json!(threshold(state).await?),
Act::ChangeAuthorityPolicy(purpose) | Act::ChangeRecognitionPolicy(purpose) => json!(
crate::policy::get_active_policy_id(&state.active_policies_ks, purpose)
.await?
.map(|id| id.to_string())
),
Act::ChangeRoles => json!(super::roles::get(&state.acl_ks, subject).await?),
Act::RestoreBackup => json!(subject),
Act::GrantsReview => json!(subject),
Act::BreakGlassReview | Act::JoinReview | Act::VettingReview => json!(subject),
Act::DirectAdmission => {
let entry = get_acl_entry(&state.acl_ks, subject).await?;
let member = crate::members::get_member(&state.members_ks, subject)
.await?
.map(|m| if m.is_removed() { "removed" } else { "active" });
json!({ "entry": entry, "member": member })
}
Act::ChangeStorage => json!({
"config": state.room_storage.config(subject).await?,
"assignment": state.room_storage.assignment(subject).await?,
}),
Act::ChangeTrustedWitnesses => {
crate::credentials::trusted_witnesses::current_value(state).await?
}
Act::ChangeJoinCriteria | Act::ChangeHiddenVetting => {
json!(crate::schemas::get_accepts(&state.schemas_ks, subject).await?)
}
Act::ChangeDirectoryPrivacy => {
let mut f = crate::directory::fields::get(state.directory.ks(), subject).await?;
if let Some(f) = f.as_mut() {
f.updated_at = None;
}
json!(f)
}
};
Ok(StatePin {
resource: subject.to_string(),
version: task_consent::domain_digest(SETTING_STATE_DOMAIN, subject, &value, None)?,
})
}
async fn state_pin(state: &AppState, subject: &str) -> Result<StatePin, AppError> {
let entry = get_acl_entry(&state.acl_ks, subject).await?;
let value = serde_json::to_value(&entry)
.map_err(|e| AppError::Internal(format!("serialise ACL entry for its state pin: {e}")))?;
Ok(StatePin {
resource: subject.to_string(),
version: task_consent::domain_digest(STATE_DOMAIN, subject, &value, None)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AdminAuthority, AdminRole, CapabilityScope, VtcRole};
fn entry(admin: AdminAuthority, expires_at: Option<u64>) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zSubject".into(),
role: VtcRole::Member,
label: None,
admin,
delegated_by: None,
created_at: 0,
created_by: "did:key:zAdmin".into(),
updated_at: None,
updated_by: None,
expires_at,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
}
}
fn listed(caps: &[&str]) -> CapabilityScope {
CapabilityScope::listed(
caps.iter()
.map(|c| c.parse::<CapRef>().unwrap().into())
.collect(),
)
.unwrap()
}
#[test]
fn vti_apv_018_newly_conferred_is_the_new_authority_conferring_capabilities() {
let now = 1_000;
let admin = entry(AdminAuthority::community_admin(), None);
let fresh = newly_conferred(None, &admin, now);
assert!(fresh.contains(&CapRef::all(Capability::RolesAssign)));
assert!(fresh.contains(&CapRef::all(Capability::ConfigAdmin)));
assert!(!fresh.contains(&CapRef::all(Capability::AuditRead)));
assert!(newly_conferred(Some(&admin), &admin, now).is_empty());
let lapsed = entry(AdminAuthority::community_admin(), Some(now));
assert!(!newly_conferred(Some(&lapsed), &admin, now).is_empty());
let moderator = entry(AdminAuthority::for_role(AdminRole::Moderator), None);
assert!(newly_conferred(None, &moderator, now).is_empty());
let mut narrow = AdminAuthority::community_admin();
narrow.capabilities = listed(&["vtc.audit.read"]);
let narrow = entry(narrow, None);
let mut wider = AdminAuthority::community_admin();
wider.capabilities = listed(&["vtc.audit.read", "vtc.roles.assign"]);
let wider = entry(wider, None);
assert_eq!(
newly_conferred(Some(&narrow), &wider, now),
vec![CapRef::all(Capability::RolesAssign)]
);
let mut rm = AdminAuthority::for_role(AdminRole::RepoManager);
rm.capabilities = listed(&["git.ns.admin@git-ns:github.com/acme"]);
assert_eq!(
newly_conferred(None, &entry(rm, None), now),
vec!["git.ns.admin@git-ns:github.com/acme".parse().unwrap()]
);
}
#[test]
fn vti_apv_019_lost_conferring_is_what_the_reduction_takes_away() {
let now = 1_000;
let admin = entry(AdminAuthority::community_admin(), None);
assert!(lost_conferring(&admin, None, now).contains(&CapRef::all(Capability::RolesAssign)));
let mut narrow = AdminAuthority::community_admin();
narrow.capabilities = listed(&["vtc.audit.read"]);
assert!(!lost_conferring(&admin, Some(&entry(narrow, None)), now).is_empty());
assert!(lost_conferring(&admin, Some(&admin), now).is_empty());
let moderator = entry(AdminAuthority::for_role(AdminRole::Moderator), None);
assert!(lost_conferring(&moderator, None, now).is_empty());
}
#[test]
fn may_approve_needs_the_capability_and_approve_authority() {
let now = 1_000;
let stake = vec![CapRef::all(Capability::RolesAssign)];
assert!(may_approve(
&entry(AdminAuthority::community_admin(), None),
&stake,
now
));
assert!(!may_approve(
&entry(AdminAuthority::for_role(AdminRole::Moderator), None),
&stake,
now
));
let mut no_approve = AdminAuthority::community_admin();
no_approve.approve = crate::acl::VtcActScope::None;
assert!(!may_approve(&entry(no_approve, None), &stake, now));
let mut rm = AdminAuthority::for_role(AdminRole::RepoManager);
rm.capabilities = listed(&["git.ns.admin@git-ns:github.com/acme"]);
rm.approve_capabilities = listed(&["git.ns.admin@git-ns:github.com/acme"]);
let rm = entry(rm, None);
let inside: Vec<CapRef> = vec!["git.ns.admin@git-ns:github.com/acme".parse().unwrap()];
let outside: Vec<CapRef> = vec!["git.ns.admin@git-ns:github.com/other".parse().unwrap()];
assert!(may_approve(&rm, &inside, now));
assert!(!may_approve(&rm, &outside, now));
}
#[test]
fn vti_acl_041_a_least_privilege_approver_counts_for_every_act() {
use crate::policy::PolicyPurpose;
let now = 1_000;
let approver = entry(AdminAuthority::for_role(AdminRole::Approver), None);
assert!(!approver.admin.can(Capability::RolesAssign, None));
for act in [
Act::GrantUnrestricted,
Act::ReduceUnrestricted,
Act::LowerThreshold,
Act::ChangeAuthorityPolicy(PolicyPurpose::Removal),
Act::ChangeRoles,
Act::RestoreBackup,
Act::GrantsReview,
Act::ChangeTrustedWitnesses,
Act::ChangeJoinCriteria,
Act::ChangeHiddenVetting,
Act::ChangeDirectoryPrivacy,
Act::ChangeRecognitionPolicy(PolicyPurpose::Personhood),
] {
assert!(may_approve(&approver, &act.default_stake(), now), "{act:?}");
}
let mut vetting_only = AdminAuthority::for_role(AdminRole::Approver);
vetting_only.approve_capabilities = listed(&["vtc.vetting.manage"]);
let vetting_only = entry(vetting_only, None);
assert!(may_approve(
&vetting_only,
&[CapRef::all(Capability::VettingManage)],
now
));
assert!(!may_approve(
&vetting_only,
&Act::GrantUnrestricted.default_stake(),
now
));
let auditor = entry(AdminAuthority::for_role(AdminRole::Auditor), None);
assert!(!may_approve(
&auditor,
&Act::LowerThreshold.default_stake(),
now
));
}
#[test]
fn every_act_names_a_kind_with_a_template() {
use crate::admin_actions::summary::template_for;
use crate::policy::PolicyPurpose;
use crate::trust_tasks as tt;
for (act, uri) in [
(Act::GrantUnrestricted, tt::ACL_GRANT_TYPE),
(Act::GrantUnrestricted, tt::ACL_CHANGE_ROLE_TYPE),
(Act::GrantUnrestricted, tt::ACL_UPDATE_TYPE),
(Act::GrantUnrestricted, tt::ACL_GRANT_V0_2_TYPE),
(Act::GrantUnrestricted, tt::ACL_CHANGE_ROLE_V0_2_TYPE),
(Act::GrantUnrestricted, tt::ACL_UPDATE_V0_2_TYPE),
(Act::GrantUnrestricted, tt::admin_tasks::INVITES_CREATE_TYPE),
(Act::ReduceUnrestricted, tt::ACL_REVOKE_TYPE),
(Act::ReduceUnrestricted, tt::ACL_REVOKE_V0_2_TYPE),
(Act::ReduceUnrestricted, tt::ACL_CHANGE_ROLE_TYPE),
(Act::ReduceUnrestricted, tt::ACL_CHANGE_ROLE_V0_2_TYPE),
(Act::ReduceUnrestricted, tt::ACL_UPDATE_TYPE),
(Act::ReduceUnrestricted, tt::ACL_UPDATE_V0_2_TYPE),
(Act::ReduceUnrestricted, tt::ACL_GRANT_TYPE),
(Act::ReduceUnrestricted, tt::MEMBER_ADMIN_REMOVE_TYPE),
(Act::LowerThreshold, tt::admin_tasks::CONFIG_PATCH_TYPE),
(Act::LowerThreshold, tt::CONFIG_IMPORT_TYPE),
(
Act::ChangeTrustedWitnesses,
tt::admin_tasks::CONFIG_PATCH_TYPE,
),
(Act::ChangeTrustedWitnesses, tt::CONFIG_IMPORT_TYPE),
(
Act::ChangeJoinCriteria,
tt::surface_tasks::ACCEPTS_REGISTER_TYPE,
),
(
Act::ChangeJoinCriteria,
tt::surface_tasks::ACCEPTS_DELETE_TYPE,
),
(
Act::ChangeHiddenVetting,
crate::admin_actions::summary::HIDDEN_PUBLISH,
),
(
Act::ChangeDirectoryPrivacy,
crate::trust_tasks::directory_tasks::FIELDS_UPDATE_TYPE,
),
(
Act::ChangeHiddenVetting,
crate::admin_actions::summary::HIDDEN_WITHDRAW,
),
(
Act::ChangeRecognitionPolicy(PolicyPurpose::Personhood),
tt::policy_tasks::POLICY_UPSERT_TYPE,
),
(
Act::ChangeRecognitionPolicy(PolicyPurpose::Personhood),
tt::policy_tasks::POLICY_ACTIVATE_TYPE,
),
(
Act::ChangeAuthorityPolicy(PolicyPurpose::Removal),
tt::policy_tasks::POLICY_UPSERT_TYPE,
),
(
Act::ChangeAuthorityPolicy(PolicyPurpose::Join),
tt::policy_tasks::POLICY_ACTIVATE_TYPE,
),
] {
assert!(
template_for(act.kind(uri), uri).is_some(),
"no summary template for ({}, {uri})",
act.kind(uri)
);
}
assert_eq!(
Act::GrantUnrestricted.kind(tt::admin_tasks::INVITES_CREATE_TYPE),
"admin.invite.create"
);
}
}