use super::facts::{Facts, Purpose};
use super::verdict::Verdict;
use crate::schemas::accepts::Admission;
pub const ADMIN_ROLE: &str = "admin";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Invariant {
PrivilegeCeiling,
StepUpForAdmin,
SelfPromotion,
AdmissionRole,
}
impl Invariant {
pub fn code(self) -> &'static str {
match self {
Invariant::PrivilegeCeiling => "privilege-ceiling",
Invariant::StepUpForAdmin => "step-up-required",
Invariant::SelfPromotion => "self-promotion",
Invariant::AdmissionRole => "admission-role",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvariantViolation {
pub invariant: Invariant,
pub detail: String,
}
impl InvariantViolation {
pub fn into_deny(self) -> Verdict {
Verdict::Deny(super::verdict::Deny {
code: self.invariant.code().into(),
reason: Some(self.detail),
})
}
}
pub fn enforce(facts: &Facts, verdict: Verdict) -> Result<Verdict, InvariantViolation> {
let Verdict::Allow(allow) = &verdict else {
return Ok(verdict);
};
let grants_admin = allow.role.as_deref() == Some(ADMIN_ROLE);
match facts.purpose {
Purpose::Join if grants_admin => Err(InvariantViolation {
invariant: Invariant::PrivilegeCeiling,
detail: "join policy may not grant the admin role".into(),
}),
Purpose::RoleChange if grants_admin && facts.actor.did == facts.subject.did => {
Err(InvariantViolation {
invariant: Invariant::SelfPromotion,
detail: "admin elevation requires a separate admin caller".into(),
})
}
Purpose::RoleChange if grants_admin && !step_up_verified(facts) => {
Err(InvariantViolation {
invariant: Invariant::StepUpForAdmin,
detail: "admin promotion requires a verified step-up".into(),
})
}
Purpose::Admission
if allow
.role
.as_deref()
.is_some_and(|r| Some(r) != requested_admission_role(facts)) =>
{
Err(InvariantViolation {
invariant: Invariant::AdmissionRole,
detail: "an admission policy may admit, refer or refuse, but not choose the role"
.into(),
})
}
_ => Ok(verdict),
}
}
fn requested_admission_role(facts: &Facts) -> Option<&str> {
facts
.evidence
.request
.as_ref()
.and_then(|r| r.get("role"))
.and_then(serde_json::Value::as_str)
}
pub fn hold_to_criterion(facts: &Facts, verdict: Verdict) -> (Verdict, Option<&'static str>) {
if facts.purpose != Purpose::Join {
return (verdict, None);
}
let admits = matches!(verdict, Verdict::Allow(_) | Verdict::Refer(_));
let Some(criterion) = facts.evidence.criterion.as_ref() else {
if !admits {
return (verdict, None);
}
return (
Verdict::Deny(super::verdict::Deny {
code: CRITERION_ABSENT.into(),
reason: Some("a join is decided under a published criterion, and none was".into()),
}),
Some(CRITERION_ABSENT),
);
};
if !criterion.met {
if !admits {
return (verdict, None);
}
return (
Verdict::RequestMore(super::verdict::RequestMore {
needs: criterion.needs.clone(),
presentation_definition: serde_json::Value::Null,
}),
Some(CRITERION_UNMET),
);
}
match (criterion.admission, verdict) {
(Admission::Review, Verdict::Allow(_)) => (
Verdict::Refer(super::verdict::Refer {
queue: REVIEW_QUEUE.into(),
reason: Some(format!(
"criterion `{}` admits after an administrator's review",
criterion.id
)),
}),
Some(CRITERION_REVIEW),
),
(_, verdict) => (verdict, None),
}
}
pub const CRITERION_UNMET: &str = "criterion-unmet";
pub const CRITERION_REVIEW: &str = "criterion-review";
pub const CRITERION_ABSENT: &str = "no-criterion";
pub const REVIEW_QUEUE: &str = "admin-review";
fn step_up_verified(facts: &Facts) -> bool {
facts
.evidence
.request
.as_ref()
.and_then(|r| r.get("step_up"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ceremony::facts::{Actor, Context, Evidence, State, Subject};
use crate::ceremony::verdict::Allow;
use serde_json::json;
fn facts(purpose: Purpose, request: serde_json::Value) -> Facts {
Facts {
purpose,
now: "2026-05-30T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:zActor".into(),
role: Some("admin".into()),
authenticated: true,
},
subject: Subject {
did: "did:key:zTarget".into(),
},
context: Context {
community_did: "did:webvh:acme.example".into(),
channel: "rest".into(),
member_count: 10,
thread_id: None,
},
evidence: Evidence {
vetting: None,
invitation: None,
presentation: None,
request: Some(request),
criterion: None,
},
state: State {
subject_member: None,
},
}
}
fn allow_role(role: &str) -> Verdict {
Verdict::Allow(Allow {
role: Some(role.into()),
..Default::default()
})
}
#[test]
fn admission_policy_may_not_choose_the_role() {
let f = facts(
Purpose::Admission,
json!({ "action": "add", "role": "member" }),
);
let bare = Verdict::Allow(Allow::default());
assert_eq!(enforce(&f, bare.clone()).unwrap(), bare);
assert_eq!(
enforce(&f, allow_role("member")).unwrap(),
allow_role("member")
);
for other in ["admin", "moderator"] {
let violation = enforce(&f, allow_role(other)).expect_err("another role is vetoed");
assert_eq!(violation.invariant, Invariant::AdmissionRole);
assert_eq!(violation.into_deny().effect(), "deny");
}
let own = facts(
Purpose::Admission,
json!({ "action": "make-membership", "role": "admin" }),
);
assert!(enforce(&own, allow_role("admin")).is_ok());
}
#[test]
fn join_member_grant_passes() {
let f = facts(Purpose::Join, json!({}));
assert_eq!(
enforce(&f, allow_role("member")).unwrap(),
allow_role("member")
);
}
#[test]
fn join_admin_grant_is_vetoed() {
let f = facts(Purpose::Join, json!({}));
let violation = enforce(&f, allow_role("admin")).expect_err("join admin must be vetoed");
assert_eq!(violation.invariant, Invariant::PrivilegeCeiling);
match violation.into_deny() {
Verdict::Deny(d) => assert_eq!(d.code, "privilege-ceiling"),
other => panic!("expected deny, got {other:?}"),
}
}
#[test]
fn role_change_admin_with_step_up_passes() {
let f = facts(
Purpose::RoleChange,
json!({ "target_role": "admin", "step_up": true }),
);
assert_eq!(
enforce(&f, allow_role("admin")).unwrap(),
allow_role("admin")
);
}
#[test]
fn role_change_admin_without_step_up_is_vetoed() {
let f = facts(
Purpose::RoleChange,
json!({ "target_role": "admin", "step_up": false }),
);
let violation = enforce(&f, allow_role("admin")).expect_err("admin needs step-up");
assert_eq!(violation.invariant, Invariant::StepUpForAdmin);
let f2 = facts(Purpose::RoleChange, json!({ "target_role": "admin" }));
assert!(enforce(&f2, allow_role("admin")).is_err());
}
#[test]
fn role_change_admin_to_self_is_vetoed_even_with_step_up() {
let mut f = facts(
Purpose::RoleChange,
json!({ "target_role": "admin", "step_up": true }),
);
f.subject.did = f.actor.did.clone();
let violation =
enforce(&f, allow_role("admin")).expect_err("self-promotion must be vetoed");
assert_eq!(violation.invariant, Invariant::SelfPromotion);
match violation.into_deny() {
Verdict::Deny(d) => assert_eq!(d.code, "self-promotion"),
other => panic!("expected deny, got {other:?}"),
}
}
#[test]
fn role_change_to_self_at_a_lesser_role_passes() {
let mut f = facts(Purpose::RoleChange, json!({ "target_role": "member" }));
f.subject.did = f.actor.did.clone();
assert_eq!(
enforce(&f, allow_role("member")).unwrap(),
allow_role("member")
);
}
#[test]
fn role_change_non_admin_passes_without_step_up() {
let f = facts(Purpose::RoleChange, json!({ "target_role": "moderator" }));
assert_eq!(
enforce(&f, allow_role("moderator")).unwrap(),
allow_role("moderator")
);
}
#[test]
fn non_allow_verdicts_pass_through() {
let f = facts(Purpose::Join, json!({}));
let refer = Verdict::Refer(super::super::verdict::Refer {
queue: "moderator".into(),
reason: None,
});
assert_eq!(enforce(&f, refer.clone()).unwrap(), refer);
}
fn with_criterion(admission: Admission, met: bool) -> Facts {
let mut f = facts(Purpose::Join, json!({}));
f.evidence.criterion = Some(crate::join::criteria::CriterionFact {
id: "kernel".into(),
requirements_digest: "zKernel".into(),
admission,
met,
needs: if met {
vec![]
} else {
vec!["invitation".into()]
},
cited: false,
superseded: false,
});
f
}
fn refer() -> Verdict {
Verdict::Refer(super::super::verdict::Refer {
queue: "moderator".into(),
reason: None,
})
}
#[test]
fn a_met_automatic_criterion_keeps_the_policys_verdict() {
let f = with_criterion(Admission::Automatic, true);
assert_eq!(
hold_to_criterion(&f, allow_role("member")).0,
allow_role("member")
);
assert_eq!(hold_to_criterion(&f, refer()).0, refer());
}
#[test]
fn a_met_review_criterion_turns_an_admission_into_a_referral() {
let f = with_criterion(Admission::Review, true);
let (v, why) = hold_to_criterion(&f, allow_role("member"));
assert_eq!(why, Some(CRITERION_REVIEW));
match v {
Verdict::Refer(r) => assert_eq!(r.queue, REVIEW_QUEUE),
other => panic!("expected refer, got {other:?}"),
}
}
#[test]
fn an_unmet_criterion_is_neither_admitted_nor_referred() {
for admission in [Admission::Automatic, Admission::Review] {
let f = with_criterion(admission, false);
for proposed in [allow_role("member"), refer()] {
let (v, why) = hold_to_criterion(&f, proposed);
assert_eq!(why, Some(CRITERION_UNMET));
match v {
Verdict::RequestMore(m) => assert_eq!(m.needs, ["invitation"]),
other => panic!("expected requestMore, got {other:?}"),
}
}
let deny = Verdict::Deny(super::super::verdict::Deny {
code: "closed".into(),
reason: None,
});
assert_eq!(hold_to_criterion(&f, deny.clone()).0, deny);
}
}
#[test]
fn a_join_without_a_criterion_is_never_admitted() {
let f = facts(Purpose::Join, json!({}));
match hold_to_criterion(&f, allow_role("member")).0 {
Verdict::Deny(d) => assert_eq!(d.code, CRITERION_ABSENT),
other => panic!("expected deny, got {other:?}"),
}
}
#[test]
fn other_purposes_are_not_held_to_a_criterion() {
let f = facts(Purpose::RoleChange, json!({ "target_role": "member" }));
assert_eq!(
hold_to_criterion(&f, allow_role("member")).0,
allow_role("member")
);
}
}