use serde_json::json;
use tracing::{info, warn};
use vti_common::audit::{AuditEvent, MemberAdmittedDirectlyData};
use super::effects::{ADMISSION_ADD, ADMISSION_MAKE_MEMBERSHIP, EffectPlan};
use super::execute::{self, AdmitOutcome, EffectOutcome};
use super::{Evidence, FactsInputs, Purpose, Verdict, VerifiedFacts, assemble_facts, decide};
use crate::acl::admin_consent::{self, Act, Operation, SignedGate};
use crate::acl::{Capability, VtcAclEntry, VtcRole, get_acl_entry};
use crate::auth::session::now_epoch;
use crate::error::{AppError, TaskError};
use crate::members::get_member;
use crate::policy::{PolicyPurpose, load_active_compiled};
use crate::server::AppState;
pub const LABEL_MAX: usize = 256;
#[derive(Debug, Clone)]
pub enum AdmissionRequest {
Add {
subject: String,
role: VtcRole,
label: Option<String>,
},
MakeMembership { subject: String },
}
impl AdmissionRequest {
pub fn subject(&self) -> &str {
match self {
Self::Add { subject, .. } | Self::MakeMembership { subject } => subject,
}
}
pub fn action(&self) -> &'static str {
match self {
Self::Add { .. } => ADMISSION_ADD,
Self::MakeMembership { .. } => ADMISSION_MAKE_MEMBERSHIP,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Approval {
PolicyAllowed,
Approved,
Waived,
}
impl Approval {
pub fn as_str(self) -> &'static str {
match self {
Self::PolicyAllowed => "policyAllowed",
Self::Approved => "approved",
Self::Waived => "waived",
}
}
}
#[derive(Debug)]
pub struct Admitted {
pub subject: String,
pub role: VtcRole,
pub label: Option<String>,
pub returning: bool,
pub approval: Approval,
pub creds: AdmitOutcome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MakeMembershipRefusal {
NoEntry,
Application,
AlreadyMember,
Removed,
NotLive,
}
pub mod codes {
use trust_tasks_rs::specs::vtc::members::admit::v0_1::error_codes as admit;
use trust_tasks_rs::specs::vtc::members::make_membership::v0_1::error_codes as make;
pub const ADMIT_HAS_ENTRY: &str = admit::HAS_ENTRY.code;
pub const ADMIT_ALREADY_MEMBER: &str = admit::ALREADY_MEMBER.code;
pub const ADMIT_ROLE_NOT_PERMITTED: &str = admit::ROLE_NOT_PERMITTED.code;
pub const ADMIT_DENIED_BY_POLICY: &str = admit::DENIED_BY_POLICY.code;
pub const MAKE_NO_ENTRY: &str = make::NO_ENTRY.code;
pub const MAKE_APPLICATION_ENTRY: &str = make::APPLICATION_ENTRY.code;
pub const MAKE_ALREADY_MEMBER: &str = make::ALREADY_MEMBER.code;
pub const MAKE_REMOVED_MEMBER: &str = make::REMOVED_MEMBER.code;
pub const MAKE_ENTRY_NOT_LIVE: &str = make::ENTRY_NOT_LIVE.code;
pub const MAKE_DENIED_BY_POLICY: &str = make::DENIED_BY_POLICY.code;
}
struct Checked {
role: VtcRole,
returning: bool,
administrative: bool,
grants_authority: bool,
}
pub async fn admit_directly(
state: &AppState,
actor_did: &str,
req: AdmissionRequest,
op: Operation<'_>,
reason: Option<&str>,
) -> Result<Admitted, TaskError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let subject = req.subject().to_string();
vti_common::identifier::validate_did("subject", &subject)?;
let actor = get_acl_entry(&state.acl_ks, actor_did)
.await?
.ok_or_else(|| {
crate::acl::capability_refusal(actor_did, Capability::MembersManage, None)
})?;
let now = now_epoch();
if actor.is_expired(now) || actor.is_suspended() || !actor.can(Capability::MembersManage, None)
{
return Err(
crate::acl::capability_refusal(actor_did, Capability::MembersManage, None).into(),
);
}
let checked = match &req {
AdmissionRequest::Add { role, label, .. } => {
check_add(state, &actor, &subject, role, label.as_deref(), now).await?
}
AdmissionRequest::MakeMembership { .. } => {
check_make_membership(state, &subject, now).await?
}
};
let verdict = decide_admission(state, &actor, &req, &checked).await?;
let denied = match &req {
AdmissionRequest::Add { .. } => codes::ADMIT_DENIED_BY_POLICY,
AdmissionRequest::MakeMembership { .. } => codes::MAKE_DENIED_BY_POLICY,
};
let summary = summary_of(&req, &checked);
let executing = crate::admin_actions::executing().is_some();
let approval = match verdict {
Verdict::Allow(_) => {
if checked.grants_authority && !executing {
match crate::acl::bound_step_up::redeem_or_request(
state,
actor_did,
op.type_uri,
op.payload,
&summary,
)
.await?
{
crate::acl::bound_step_up::Gate::Satisfied => {}
crate::acl::bound_step_up::Gate::Required(request) => {
return Err(TaskError::step_up(request));
}
}
}
Approval::PolicyAllowed
}
Verdict::Refer(r) => {
info!(
subject = %subject,
action = req.action(),
queue = %r.queue,
"direct admission referred for approval by the admission policy"
);
match admin_consent::gesture_then_consent_for(
state,
Act::DirectAdmission,
&[],
actor_did,
&subject,
op,
&summary,
&summary,
)
.await?
{
SignedGate::StepUpRequired(request) => return Err(TaskError::step_up(request)),
SignedGate::Ready(ready) => {
let approval = if ready.is_waived() {
Approval::Waived
} else {
Approval::Approved
};
ready.spend(state).await?;
approval
}
}
}
Verdict::Deny(d) => {
return Err(TaskError::declared(
denied,
AppError::Forbidden(format!(
"this community's admission policy refuses it ({}{})",
d.code,
d.reason.map(|r| format!(": {r}")).unwrap_or_default()
)),
));
}
Verdict::RequestMore(_) => {
return Err(AppError::Internal(
"the admission policy returned request_more; a direct admission presents no \
evidence to add"
.into(),
)
.into());
}
};
let (plan, label) = match &req {
AdmissionRequest::Add { role, label, .. } => (
EffectPlan::Admit {
subject: subject.clone(),
role: role.to_string(),
obligations: vec![],
publish_consent: false,
label: label.clone(),
},
label.clone(),
),
AdmissionRequest::MakeMembership { .. } => (
EffectPlan::Enrol {
subject: subject.clone(),
},
None,
),
};
let EffectOutcome::Admitted(creds) = execute::apply(state, plan, actor_did).await? else {
return Err(AppError::Internal("admit effect did not produce credentials".into()).into());
};
crate::admin_actions::record_effect(state).await;
let action_id = crate::admin_actions::executing().map(|e| e.action_id);
audit_writer
.write(
actor_did,
Some(&subject),
AuditEvent::MemberAdmittedDirectly({
let mut data = MemberAdmittedDirectlyData::new(
req.action(),
checked.role.to_string(),
approval.as_str(),
);
data.label = label.clone();
data.returning = checked.returning;
data.action_id = action_id.clone();
data.reason = reason.map(str::to_string);
data
}),
)
.await?;
crate::join::emit_admit_audit(
audit_writer,
actor_did,
&subject,
&creds,
&checked.role.to_string(),
None,
)
.await?;
if let Err(e) =
crate::credentials::delivery::deliver_membership_credentials(state, &subject, &creds).await
{
warn!(
subject = %subject,
error = %e,
"membership-credential delivery failed on a direct admission; credentials issued and \
returned inline"
);
}
info!(
subject = %subject,
admin = %actor_did,
action = req.action(),
role = %checked.role,
returning = checked.returning,
approval = approval.as_str(),
status_list_index = creds.status_list_index,
"member admitted directly"
);
Ok(Admitted {
subject,
role: checked.role,
label,
returning: checked.returning,
approval,
creds: *creds,
})
}
async fn check_add(
state: &AppState,
actor: &VtcAclEntry,
subject: &str,
role: &VtcRole,
label: Option<&str>,
now: u64,
) -> Result<Checked, TaskError> {
if let Some(label) = label
&& (label.trim().is_empty() || label.chars().count() > LABEL_MAX)
{
return Err(AppError::Validation(format!(
"label must be 1 to {LABEL_MAX} characters, not blank"
))
.into());
}
let not_permitted = |message: String| {
TaskError::declared(
codes::ADMIT_ROLE_NOT_PERMITTED,
AppError::Forbidden(message),
)
};
if !role.is_assignable() {
return Err(not_permitted(
"`application` is not a membership: it marks a non-member holding git rights".into(),
));
}
if matches!(role, VtcRole::Admin) {
return Err(not_permitted(format!(
"a direct admission never seats `admin`: add {subject} as a member, then promote them \
with acl/change-role, which takes its own passkey gesture and another \
administrator's consent (VTI-APV-018)"
)));
}
let seated = VtcAclEntry::new(subject, role.clone(), role.implied_authority(), &actor.did);
if !matches!(role, VtcRole::Member) {
if !actor.can_any(Capability::RolesAssign) {
return Err(not_permitted(format!(
"admitting {subject} as `{role}` assigns a role, which takes vtc.roles.assign; \
without it you may add members only"
)));
}
if let Err(refusal) = crate::acl::granting::check_write(actor, &seated, now) {
return Err(not_permitted(format!(
"admitting {subject} as `{role}` grants what that role implies: {refusal}"
)));
}
let conferred = admin_consent::newly_conferred(None, &seated, now);
if !conferred.is_empty() {
return Err(not_permitted(format!(
"`{role}` would give {subject} {}, which create authority; add them as a member \
and grant it with acl/grant, which takes other administrators' consent \
(VTI-APV-018)",
conferred
.iter()
.map(crate::acl::CapRef::display)
.collect::<Vec<_>>()
.join(", ")
)));
}
}
match get_acl_entry(&state.acl_ks, subject).await? {
Some(e) if e.is_application() => {}
Some(_) => {
return Err(TaskError::declared(
codes::ADMIT_HAS_ENTRY,
AppError::Conflict(format!(
"{subject} already has an access-control entry; if it has no membership, \
make it one with vtc/members/make-membership {{\"subject\": \"{subject}\"}}, \
which keeps its role and authority"
)),
));
}
None => {}
}
let returning = match get_member(&state.members_ks, subject).await? {
Some(m) if m.is_removed() => true,
Some(_) => {
return Err(TaskError::declared(
codes::ADMIT_ALREADY_MEMBER,
AppError::Conflict(format!("{subject} is already a member")),
));
}
None => false,
};
Ok(Checked {
role: role.clone(),
returning,
administrative: seated.is_administrator(),
grants_authority: seated.is_administrator(),
})
}
async fn check_make_membership(
state: &AppState,
subject: &str,
now: u64,
) -> Result<Checked, TaskError> {
let refuse = |why: MakeMembershipRefusal, message: String| {
let (code, error) = match why {
MakeMembershipRefusal::NoEntry => (codes::MAKE_NO_ENTRY, AppError::NotFound(message)),
MakeMembershipRefusal::Application => {
(codes::MAKE_APPLICATION_ENTRY, AppError::Conflict(message))
}
MakeMembershipRefusal::AlreadyMember => {
(codes::MAKE_ALREADY_MEMBER, AppError::Conflict(message))
}
MakeMembershipRefusal::Removed => {
(codes::MAKE_REMOVED_MEMBER, AppError::Conflict(message))
}
MakeMembershipRefusal::NotLive => {
(codes::MAKE_ENTRY_NOT_LIVE, AppError::Conflict(message))
}
};
TaskError::declared(code, error)
};
let Some(entry) = get_acl_entry(&state.acl_ks, subject).await? else {
return Err(refuse(
MakeMembershipRefusal::NoEntry,
format!(
"{subject} has no access-control entry; add them as a member with \
vtc/members/admit instead"
),
));
};
if entry.is_application() {
return Err(refuse(
MakeMembershipRefusal::Application,
format!(
"{subject}'s entry is an application entry — git rights held by a non-member \
(VTI-ACL-037). It becomes a membership through the join flow, or with \
vtc/members/admit, which keeps its grants"
),
));
}
if entry.is_expired(now) || entry.is_suspended() {
return Err(refuse(
MakeMembershipRefusal::NotLive,
format!("{subject}'s entry has expired or is suspended; it authorizes nothing now"),
));
}
match get_member(&state.members_ks, subject).await? {
Some(m) if m.is_removed() => {
return Err(refuse(
MakeMembershipRefusal::Removed,
format!(
"{subject} was a member and was removed{}. Making their entry a membership \
would undo that removal without the decision re-admitting a former member \
takes. Re-admit them through the join flow, or revoke the entry and add \
them with vtc/members/admit, which the admission policy decides",
m.removed_at
.map(|t| format!(" on {}", t.to_rfc3339()))
.unwrap_or_default()
),
));
}
Some(_) => {
return Err(refuse(
MakeMembershipRefusal::AlreadyMember,
format!("{subject} is already a member"),
));
}
None => {}
}
Ok(Checked {
administrative: entry.is_administrator(),
grants_authority: false,
role: entry.role,
returning: false,
})
}
async fn decide_admission(
state: &AppState,
actor: &VtcAclEntry,
req: &AdmissionRequest,
checked: &Checked,
) -> Result<Verdict, AppError> {
let subject = req.subject();
let prior = get_member(&state.members_ks, subject).await?;
let facts = assemble_facts(
state,
FactsInputs {
purpose: Purpose::Admission,
actor_did: actor.did.clone(),
actor_role: Some(actor.role.to_string()),
subject_did: subject.to_string(),
subject_member: prior
.as_ref()
.map(|m| super::member_state(checked.role.to_string(), Some(m))),
evidence: Evidence {
request: Some(json!({
"action": req.action(),
"role": checked.role.to_string(),
"administrative": checked.administrative,
"returning": checked.returning,
})),
..Evidence::default()
},
thread_id: None,
},
)
.await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Admission,
)
.await?;
decide(&verified, &policy)
}
fn summary_of(req: &AdmissionRequest, checked: &Checked) -> String {
match req {
AdmissionRequest::Add { subject, .. } => format!(
"Add {subject} as a member holding {}, without a join request{}",
checked.role,
if checked.returning {
" (a former member)"
} else {
""
}
),
AdmissionRequest::MakeMembership { subject } => format!(
"Make {subject}'s access-control entry a membership, at its role {}",
checked.role
),
}
}