use super::facts::{Facts, Purpose};
use super::verdict::Verdict;
pub const ADMIN_ROLE: &str = "admin";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Invariant {
PrivilegeCeiling,
StepUpForAdmin,
}
impl Invariant {
pub fn code(self) -> &'static str {
match self {
Invariant::PrivilegeCeiling => "privilege-ceiling",
Invariant::StepUpForAdmin => "step-up-required",
}
}
}
#[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 && !step_up_verified(facts) => {
Err(InvariantViolation {
invariant: Invariant::StepUpForAdmin,
detail: "admin promotion requires a verified step-up".into(),
})
}
_ => Ok(verdict),
}
}
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,
},
evidence: Evidence {
invitation: None,
presentation: None,
request: Some(request),
},
state: State {
subject_member: None,
},
}
}
fn allow_role(role: &str) -> Verdict {
Verdict::Allow(Allow {
role: Some(role.into()),
..Default::default()
})
}
#[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_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);
}
}