use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PolicyV2Config {
pub version: u32,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub rules: Vec<PolicyRule>,
#[serde(default)]
pub permissions: Option<PermissionsConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PolicyRule {
pub id: String,
#[serde(default)]
pub when: PolicyWhen,
#[serde(default)]
pub stages: Vec<PolicyStageDef>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct PolicyWhen {
#[serde(default)]
pub default: Option<bool>,
#[serde(default)]
pub labels_any: Option<Vec<String>>,
#[serde(default)]
pub failed_cmd: Option<bool>,
#[serde(default)]
pub evidence_count_gte: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PolicyStageDef {
pub stage_id: String,
pub role: String,
#[serde(default = "default_one")]
pub min_approvals: usize,
#[serde(default = "default_two")]
pub max_assignees: usize,
}
fn default_one() -> usize {
1
}
fn default_two() -> usize {
2
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ActorsConfig {
pub version: u32,
#[serde(default)]
pub actors: BTreeMap<String, ActorDef>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ActorKind {
#[default]
User,
Agent,
}
impl std::fmt::Display for ActorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ActorKind::User => write!(f, "user"),
ActorKind::Agent => write!(f, "agent"),
}
}
}
impl std::str::FromStr for ActorKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(ActorKind::User),
"agent" => Ok(ActorKind::Agent),
other => anyhow::bail!("Actor kind must be 'user' or 'agent', got: {other}"),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ActorDef {
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub kind: ActorKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
}
impl Default for ActorsConfig {
fn default() -> Self {
Self {
version: 1,
actors: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionsConfig {
#[serde(default = "default_deny")]
pub default: String,
#[serde(default)]
pub grants: Vec<PermissionGrant>,
}
fn default_deny() -> String {
"deny".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionGrant {
pub actions: Vec<String>,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthzRequest {
pub actor: String,
pub action: String,
#[serde(default)]
pub resource: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthzResult {
pub allowed: bool,
pub actor_roles: Vec<String>,
pub matched_grant: Option<PermissionGrant>,
pub policy_default: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub fn evaluate_authz(
req: &AuthzRequest,
policy: &PolicyV2Config,
actors: &ActorsConfig,
) -> AuthzResult {
let actor_roles: Vec<String> = actors
.actors
.get(&req.actor)
.map(|a| a.roles.clone())
.unwrap_or_default();
let permissions = match &policy.permissions {
Some(p) => p,
None => {
return AuthzResult {
allowed: false,
actor_roles,
matched_grant: None,
policy_default: "deny".to_string(),
reason: Some("no permissions section in policy".to_string()),
};
}
};
let policy_default = &permissions.default;
for grant in &permissions.grants {
if !grant.actions.contains(&req.action) {
continue;
}
let role_match = grant
.roles
.iter()
.any(|r| r == "*" || actor_roles.iter().any(|ar| ar == r));
if role_match {
return AuthzResult {
allowed: true,
actor_roles,
matched_grant: Some(grant.clone()),
policy_default: policy_default.clone(),
reason: None,
};
}
}
let allowed = policy_default == "allow";
let reason = if allowed {
None
} else {
Some(format!(
"no grant matches action '{}' for roles {:?}",
req.action, actor_roles
))
};
AuthzResult {
allowed,
actor_roles,
matched_grant: None,
policy_default: policy_default.clone(),
reason,
}
}
pub fn load_policy_from_dir(edda_dir: &Path) -> anyhow::Result<PolicyV2Config> {
let path = edda_dir.join("policy.yaml");
if !path.exists() {
return Ok(PolicyV2Config {
version: 2,
roles: vec![],
rules: vec![PolicyRule {
id: "default".to_string(),
when: PolicyWhen {
default: Some(true),
..Default::default()
},
stages: vec![],
}],
permissions: None,
});
}
let content = std::fs::read(&path)?;
let v2: PolicyV2Config = serde_yaml::from_slice(&content)?;
Ok(v2)
}
pub fn load_actors_from_dir(edda_dir: &Path) -> anyhow::Result<ActorsConfig> {
let path = edda_dir.join("actors.yaml");
if !path.exists() {
return Ok(ActorsConfig::default());
}
let content = std::fs::read(&path)?;
let cfg: ActorsConfig = serde_yaml::from_slice(&content)?;
Ok(cfg)
}
pub fn save_actors_to_dir(edda_dir: &Path, cfg: &ActorsConfig) -> anyhow::Result<()> {
let path = edda_dir.join("actors.yaml");
let yaml = serde_yaml::to_string(cfg)?;
std::fs::write(&path, yaml.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_policy(default: &str) -> PolicyV2Config {
PolicyV2Config {
version: 2,
roles: vec!["lead".into(), "reviewer".into(), "operator".into()],
rules: vec![],
permissions: Some(PermissionsConfig {
default: default.to_string(),
grants: vec![
PermissionGrant {
actions: vec!["deploy".into(), "rollback".into()],
roles: vec!["lead".into(), "operator".into()],
},
PermissionGrant {
actions: vec!["merge".into(), "approve".into()],
roles: vec!["lead".into(), "reviewer".into()],
},
PermissionGrant {
actions: vec!["read".into()],
roles: vec!["*".into()],
},
],
}),
}
}
fn actors_with(name: &str, roles: &[&str]) -> ActorsConfig {
let mut actors = BTreeMap::new();
actors.insert(
name.to_string(),
ActorDef {
roles: roles.iter().map(|s| s.to_string()).collect(),
kind: ActorKind::User,
email: None,
display_name: None,
runtime: None,
},
);
ActorsConfig { version: 1, actors }
}
#[test]
fn test_evaluate_authz_allow() {
let policy = sample_policy("deny");
let actors = actors_with("alice", &["operator"]);
let req = AuthzRequest {
actor: "alice".into(),
action: "deploy".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(result.allowed);
assert!(result.matched_grant.is_some());
assert_eq!(result.actor_roles, vec!["operator"]);
}
#[test]
fn test_evaluate_authz_deny_no_role() {
let policy = sample_policy("deny");
let actors = actors_with("bob", &["reviewer"]);
let req = AuthzRequest {
actor: "bob".into(),
action: "deploy".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(!result.allowed);
assert!(result.matched_grant.is_none());
assert!(result.reason.is_some());
}
#[test]
fn test_evaluate_authz_default_deny() {
let policy = sample_policy("deny");
let actors = actors_with("alice", &["operator"]);
let req = AuthzRequest {
actor: "alice".into(),
action: "unknown_action".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(!result.allowed);
assert_eq!(result.policy_default, "deny");
}
#[test]
fn test_evaluate_authz_default_allow() {
let policy = sample_policy("allow");
let actors = actors_with("alice", &["operator"]);
let req = AuthzRequest {
actor: "alice".into(),
action: "unknown_action".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(result.allowed);
assert_eq!(result.policy_default, "allow");
}
#[test]
fn test_evaluate_authz_wildcard_role() {
let policy = sample_policy("deny");
let actors = actors_with("charlie", &["intern"]);
let req = AuthzRequest {
actor: "charlie".into(),
action: "read".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(result.allowed, "wildcard '*' should match any role");
}
#[test]
fn test_evaluate_authz_unknown_actor() {
let policy = sample_policy("deny");
let actors = ActorsConfig::default();
let req = AuthzRequest {
actor: "nobody".into(),
action: "deploy".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(!result.allowed);
assert!(result.actor_roles.is_empty());
}
#[test]
fn test_evaluate_authz_unknown_actor_wildcard_grant() {
let policy = sample_policy("deny");
let actors = ActorsConfig::default();
let req = AuthzRequest {
actor: "nobody".into(),
action: "read".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(result.allowed);
}
#[test]
fn test_policy_v2_backward_compat() {
let yaml = r#"
version: 2
roles:
- lead
rules:
- id: default
when:
default: true
stages: []
"#;
let config: PolicyV2Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.version, 2);
assert!(config.permissions.is_none());
}
#[test]
fn test_policy_v2_with_permissions() {
let yaml = r#"
version: 2
roles:
- lead
- operator
rules: []
permissions:
default: deny
grants:
- actions: [deploy]
roles: [lead, operator]
- actions: [read]
roles: ["*"]
"#;
let config: PolicyV2Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.permissions.is_some());
let perms = config.permissions.unwrap();
assert_eq!(perms.default, "deny");
assert_eq!(perms.grants.len(), 2);
assert_eq!(perms.grants[0].actions, vec!["deploy"]);
}
#[test]
fn test_actors_v1_backward_compat() {
let yaml = r#"
version: 1
actors:
alice:
roles: [lead, reviewer]
"#;
let cfg: ActorsConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.version, 1);
let alice = cfg.actors.get("alice").unwrap();
assert_eq!(alice.roles, vec!["lead", "reviewer"]);
assert_eq!(alice.kind, ActorKind::User); assert!(alice.email.is_none());
assert!(alice.display_name.is_none());
assert!(alice.runtime.is_none());
}
#[test]
fn test_actors_v2_full_fields() {
let yaml = r#"
version: 2
actors:
alice:
kind: user
roles: [lead, reviewer]
email: alice@example.com
display_name: Alice Chen
claude-agent-1:
kind: agent
roles: [operator]
runtime: claude
"#;
let cfg: ActorsConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.version, 2);
let alice = cfg.actors.get("alice").unwrap();
assert_eq!(alice.kind, ActorKind::User);
assert_eq!(alice.email.as_deref(), Some("alice@example.com"));
assert_eq!(alice.display_name.as_deref(), Some("Alice Chen"));
assert!(alice.runtime.is_none());
let agent = cfg.actors.get("claude-agent-1").unwrap();
assert_eq!(agent.kind, ActorKind::Agent);
assert_eq!(agent.roles, vec!["operator"]);
assert_eq!(agent.runtime.as_deref(), Some("claude"));
assert!(agent.email.is_none());
}
#[test]
fn test_no_permissions_section_defaults_deny() {
let policy = PolicyV2Config {
version: 2,
roles: vec![],
rules: vec![],
permissions: None,
};
let actors = actors_with("alice", &["lead"]);
let req = AuthzRequest {
actor: "alice".into(),
action: "deploy".into(),
resource: None,
};
let result = evaluate_authz(&req, &policy, &actors);
assert!(!result.allowed);
assert!(result
.reason
.as_ref()
.unwrap()
.contains("no permissions section"));
}
}