use super::ChangeKind;
use crate::serve::rbac::{AuthContext, Role};
use faucet_core::JsonSchema;
use serde::{Deserialize, Serialize};
pub const DEFAULT_EXPIRE_SECS: u64 = 86_400;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ApprovalPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expire_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ApprovalRule>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ApprovalRule {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kinds: Vec<ChangeKind>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<Role>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub principals: Vec<String>,
#[serde(default = "one")]
pub min_approvers: u32,
#[serde(default)]
pub self_approve: bool,
}
fn one() -> u32 {
1
}
#[derive(Debug, Clone, PartialEq)]
pub struct EffectiveRule {
pub roles: Vec<Role>,
pub principals: Vec<String>,
pub min_approvers: u32,
pub self_approve: bool,
}
impl Default for EffectiveRule {
fn default() -> Self {
Self {
roles: vec![Role::Admin],
principals: Vec::new(),
min_approvers: 1,
self_approve: false,
}
}
}
impl ApprovalPolicy {
pub fn permissive() -> Self {
Self {
expire_secs: None,
rules: vec![ApprovalRule {
kinds: Vec::new(),
roles: vec![Role::Admin],
principals: Vec::new(),
min_approvers: 1,
self_approve: true,
}],
}
}
pub fn validate(&self) -> Result<(), String> {
if self.expire_secs == Some(0) {
return Err("approvals.expire_secs must be greater than 0".into());
}
for (i, r) in self.rules.iter().enumerate() {
if r.min_approvers == 0 {
return Err(format!(
"approvals.rules[{i}].min_approvers must be at least 1"
));
}
if r.principals.iter().any(|p| p.trim().is_empty()) {
return Err(format!(
"approvals.rules[{i}].principals contains an empty name"
));
}
}
Ok(())
}
pub fn expire_secs(&self) -> u64 {
self.expire_secs.unwrap_or(DEFAULT_EXPIRE_SECS)
}
pub fn effective(&self, kind: ChangeKind) -> EffectiveRule {
let rule = self
.rules
.iter()
.find(|r| r.kinds.is_empty() || r.kinds.contains(&kind));
match rule {
None => EffectiveRule::default(),
Some(r) => EffectiveRule {
roles: if r.roles.is_empty() && r.principals.is_empty() {
vec![Role::Admin]
} else {
r.roles.clone()
},
principals: r.principals.clone(),
min_approvers: r.min_approvers.max(1),
self_approve: r.self_approve,
},
}
}
pub fn may_approve(
&self,
kind: ChangeKind,
actor: &AuthContext,
requester: &str,
) -> Result<(), String> {
let rule = self.effective(kind);
let named = rule.principals.iter().any(|p| p == &actor.principal);
let by_role = rule.roles.contains(&actor.role);
if !named && !by_role {
let who = if rule.principals.is_empty() {
format!(
"role {}",
rule.roles
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(" / ")
)
} else {
format!(
"{} or role {}",
rule.principals.join(", "),
rule.roles
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(" / ")
)
};
return Err(format!(
"a {} change may be approved by {who}; '{}' is {}",
kind.as_str(),
actor.principal,
actor.role.as_str()
));
}
if actor.principal == requester && !rule.self_approve {
return Err(format!(
"'{}' requested this change and may not approve it (self-approval is off for {} changes)",
actor.principal,
kind.as_str()
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(principal: &str, role: Role) -> AuthContext {
AuthContext {
principal: principal.into(),
role,
source_ip: None,
tenant: None,
}
}
#[test]
fn default_rule_is_admins_only_without_self_approval() {
let p = ApprovalPolicy::default();
let r = p.effective(ChangeKind::Run);
assert_eq!(r, EffectiveRule::default());
assert!(
p.may_approve(ChangeKind::Run, &ctx("root", Role::Admin), "bob")
.is_ok()
);
let err = p
.may_approve(ChangeKind::Run, &ctx("op", Role::Operator), "bob")
.unwrap_err();
assert!(err.contains("role admin"), "{err}");
let err = p
.may_approve(ChangeKind::Run, &ctx("root", Role::Admin), "root")
.unwrap_err();
assert!(err.contains("self-approval"), "{err}");
assert_eq!(p.expire_secs(), DEFAULT_EXPIRE_SECS);
assert!(
ApprovalPolicy::permissive()
.may_approve(ChangeKind::Run, &ctx("token", Role::Admin), "token")
.is_ok()
);
}
#[test]
fn first_matching_rule_wins_and_principals_bypass_roles() {
let p: ApprovalPolicy = serde_yaml::from_str(
"expire_secs: 60\nrules:\n - kinds: [run]\n roles: [operator]\n self_approve: true\n - principals: [alice]\n min_approvers: 2\n",
)
.unwrap();
p.validate().unwrap();
assert_eq!(p.expire_secs(), 60);
let run = p.effective(ChangeKind::Run);
assert_eq!(run.roles, vec![Role::Operator]);
assert!(run.self_approve);
assert!(
p.may_approve(ChangeKind::Run, &ctx("op", Role::Operator), "op")
.is_ok()
);
assert!(
p.may_approve(ChangeKind::Run, &ctx("root", Role::Admin), "op")
.is_err()
);
let launch = p.effective(ChangeKind::TemplateLaunch);
assert_eq!(launch.min_approvers, 2);
assert!(launch.roles.is_empty());
assert!(
p.may_approve(
ChangeKind::TemplateLaunch,
&ctx("alice", Role::Viewer),
"bob"
)
.is_ok(),
"a named principal approves whatever their role"
);
let err = p
.may_approve(ChangeKind::TemplateLaunch, &ctx("root", Role::Admin), "bob")
.unwrap_err();
assert!(err.contains("alice"), "{err}");
}
#[test]
fn validate_refuses_zero_quorum_and_empty_names() {
let bad: ApprovalPolicy = serde_yaml::from_str("rules:\n - min_approvers: 0\n").unwrap();
assert!(bad.validate().unwrap_err().contains("min_approvers"));
let bad: ApprovalPolicy = serde_yaml::from_str("rules:\n - principals: [' ']\n").unwrap();
assert!(bad.validate().unwrap_err().contains("empty name"));
let bad: ApprovalPolicy = serde_yaml::from_str("expire_secs: 0\n").unwrap();
assert!(bad.validate().is_err());
assert!(serde_yaml::from_str::<ApprovalPolicy>("nope: 1\n").is_err());
}
}