Skip to main content

edda_core/
policy.rs

1//! Governance policy types and RBAC evaluation.
2//!
3//! Shared between `edda-cli` (draft approval workflow) and `edda-serve` (authz API).
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::path::Path;
8
9// ── Policy v2 data model ──
10
11#[derive(Debug, Serialize, Deserialize, Clone)]
12pub struct PolicyV2Config {
13    pub version: u32,
14    #[serde(default)]
15    pub roles: Vec<String>,
16    #[serde(default)]
17    pub rules: Vec<PolicyRule>,
18    /// RBAC permissions (optional, additive to v2 schema).
19    #[serde(default)]
20    pub permissions: Option<PermissionsConfig>,
21}
22
23#[derive(Debug, Serialize, Deserialize, Clone)]
24pub struct PolicyRule {
25    pub id: String,
26    #[serde(default)]
27    pub when: PolicyWhen,
28    #[serde(default)]
29    pub stages: Vec<PolicyStageDef>,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, Default)]
33pub struct PolicyWhen {
34    #[serde(default)]
35    pub default: Option<bool>,
36    #[serde(default)]
37    pub labels_any: Option<Vec<String>>,
38    #[serde(default)]
39    pub failed_cmd: Option<bool>,
40    #[serde(default)]
41    pub evidence_count_gte: Option<usize>,
42}
43
44#[derive(Debug, Serialize, Deserialize, Clone)]
45pub struct PolicyStageDef {
46    pub stage_id: String,
47    pub role: String,
48    #[serde(default = "default_one")]
49    pub min_approvals: usize,
50    #[serde(default = "default_two")]
51    pub max_assignees: usize,
52}
53
54fn default_one() -> usize {
55    1
56}
57fn default_two() -> usize {
58    2
59}
60
61// ── Actors config ──
62
63#[derive(Debug, Serialize, Deserialize, Clone)]
64pub struct ActorsConfig {
65    pub version: u32,
66    #[serde(default)]
67    pub actors: BTreeMap<String, ActorDef>,
68}
69
70/// Actor kind: distinguishes human users from automated agents.
71#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
72#[serde(rename_all = "lowercase")]
73pub enum ActorKind {
74    #[default]
75    User,
76    Agent,
77}
78
79impl std::fmt::Display for ActorKind {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ActorKind::User => write!(f, "user"),
83            ActorKind::Agent => write!(f, "agent"),
84        }
85    }
86}
87
88impl std::str::FromStr for ActorKind {
89    type Err = anyhow::Error;
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        match s {
92            "user" => Ok(ActorKind::User),
93            "agent" => Ok(ActorKind::Agent),
94            other => anyhow::bail!("Actor kind must be 'user' or 'agent', got: {other}"),
95        }
96    }
97}
98
99#[derive(Debug, Serialize, Deserialize, Clone)]
100pub struct ActorDef {
101    #[serde(default)]
102    pub roles: Vec<String>,
103    /// Actor kind: "user" or "agent". Defaults to User for v1 compat.
104    #[serde(default)]
105    pub kind: ActorKind,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub email: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub display_name: Option<String>,
110    /// For agent actors: runtime platform (e.g. "claude", "opencode").
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub runtime: Option<String>,
113}
114
115impl Default for ActorsConfig {
116    fn default() -> Self {
117        Self {
118            version: 1,
119            actors: BTreeMap::new(),
120        }
121    }
122}
123
124// ── RBAC Permissions ──
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PermissionsConfig {
128    /// "deny" or "allow" — what happens when no grant matches.
129    #[serde(default = "default_deny")]
130    pub default: String,
131    #[serde(default)]
132    pub grants: Vec<PermissionGrant>,
133}
134
135fn default_deny() -> String {
136    "deny".to_string()
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PermissionGrant {
141    pub actions: Vec<String>,
142    pub roles: Vec<String>,
143}
144
145// ── Authz request / result ──
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct AuthzRequest {
149    pub actor: String,
150    pub action: String,
151    #[serde(default)]
152    pub resource: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct AuthzResult {
157    pub allowed: bool,
158    pub actor_roles: Vec<String>,
159    pub matched_grant: Option<PermissionGrant>,
160    pub policy_default: String,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub reason: Option<String>,
163}
164
165// ── Evaluation ──
166
167/// Evaluate whether an actor is allowed to perform an action.
168///
169/// Logic:
170/// 1. Look up actor's roles from `actors`.
171/// 2. Find a matching grant in `policy.permissions.grants` where the action
172///    matches AND at least one of the actor's roles (or `"*"`) is listed.
173/// 3. If no grant matches, fall back to `permissions.default`.
174pub fn evaluate_authz(
175    req: &AuthzRequest,
176    policy: &PolicyV2Config,
177    actors: &ActorsConfig,
178) -> AuthzResult {
179    let actor_roles: Vec<String> = actors
180        .actors
181        .get(&req.actor)
182        .map(|a| a.roles.clone())
183        .unwrap_or_default();
184
185    let permissions = match &policy.permissions {
186        Some(p) => p,
187        None => {
188            // No permissions section → use default deny
189            return AuthzResult {
190                allowed: false,
191                actor_roles,
192                matched_grant: None,
193                policy_default: "deny".to_string(),
194                reason: Some("no permissions section in policy".to_string()),
195            };
196        }
197    };
198
199    let policy_default = &permissions.default;
200
201    // Search for a matching grant
202    for grant in &permissions.grants {
203        if !grant.actions.contains(&req.action) {
204            continue;
205        }
206        // Check if any of actor's roles match the grant's roles
207        let role_match = grant
208            .roles
209            .iter()
210            .any(|r| r == "*" || actor_roles.iter().any(|ar| ar == r));
211        if role_match {
212            return AuthzResult {
213                allowed: true,
214                actor_roles,
215                matched_grant: Some(grant.clone()),
216                policy_default: policy_default.clone(),
217                reason: None,
218            };
219        }
220    }
221
222    // No grant matched — apply default
223    let allowed = policy_default == "allow";
224    let reason = if allowed {
225        None
226    } else {
227        Some(format!(
228            "no grant matches action '{}' for roles {:?}",
229            req.action, actor_roles
230        ))
231    };
232
233    AuthzResult {
234        allowed,
235        actor_roles,
236        matched_grant: None,
237        policy_default: policy_default.clone(),
238        reason,
239    }
240}
241
242// ── File loading helpers ──
243
244/// Load policy.yaml from a directory containing `.edda/`.
245pub fn load_policy_from_dir(edda_dir: &Path) -> anyhow::Result<PolicyV2Config> {
246    let path = edda_dir.join("policy.yaml");
247    if !path.exists() {
248        return Ok(PolicyV2Config {
249            version: 2,
250            roles: vec![],
251            rules: vec![PolicyRule {
252                id: "default".to_string(),
253                when: PolicyWhen {
254                    default: Some(true),
255                    ..Default::default()
256                },
257                stages: vec![],
258            }],
259            permissions: None,
260        });
261    }
262    let content = std::fs::read(&path)?;
263    let v2: PolicyV2Config = serde_yaml::from_slice(&content)?;
264    Ok(v2)
265}
266
267/// Load actors.yaml from a directory containing `.edda/`.
268pub fn load_actors_from_dir(edda_dir: &Path) -> anyhow::Result<ActorsConfig> {
269    let path = edda_dir.join("actors.yaml");
270    if !path.exists() {
271        return Ok(ActorsConfig::default());
272    }
273    let content = std::fs::read(&path)?;
274    let cfg: ActorsConfig = serde_yaml::from_slice(&content)?;
275    Ok(cfg)
276}
277
278/// Save actors.yaml to the `.edda/` directory.
279pub fn save_actors_to_dir(edda_dir: &Path, cfg: &ActorsConfig) -> anyhow::Result<()> {
280    let path = edda_dir.join("actors.yaml");
281    let yaml = serde_yaml::to_string(cfg)?;
282    std::fs::write(&path, yaml.as_bytes())?;
283    Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn sample_policy(default: &str) -> PolicyV2Config {
291        PolicyV2Config {
292            version: 2,
293            roles: vec!["lead".into(), "reviewer".into(), "operator".into()],
294            rules: vec![],
295            permissions: Some(PermissionsConfig {
296                default: default.to_string(),
297                grants: vec![
298                    PermissionGrant {
299                        actions: vec!["deploy".into(), "rollback".into()],
300                        roles: vec!["lead".into(), "operator".into()],
301                    },
302                    PermissionGrant {
303                        actions: vec!["merge".into(), "approve".into()],
304                        roles: vec!["lead".into(), "reviewer".into()],
305                    },
306                    PermissionGrant {
307                        actions: vec!["read".into()],
308                        roles: vec!["*".into()],
309                    },
310                ],
311            }),
312        }
313    }
314
315    fn actors_with(name: &str, roles: &[&str]) -> ActorsConfig {
316        let mut actors = BTreeMap::new();
317        actors.insert(
318            name.to_string(),
319            ActorDef {
320                roles: roles.iter().map(|s| s.to_string()).collect(),
321                kind: ActorKind::User,
322                email: None,
323                display_name: None,
324                runtime: None,
325            },
326        );
327        ActorsConfig { version: 1, actors }
328    }
329
330    #[test]
331    fn test_evaluate_authz_allow() {
332        let policy = sample_policy("deny");
333        let actors = actors_with("alice", &["operator"]);
334        let req = AuthzRequest {
335            actor: "alice".into(),
336            action: "deploy".into(),
337            resource: None,
338        };
339        let result = evaluate_authz(&req, &policy, &actors);
340        assert!(result.allowed);
341        assert!(result.matched_grant.is_some());
342        assert_eq!(result.actor_roles, vec!["operator"]);
343    }
344
345    #[test]
346    fn test_evaluate_authz_deny_no_role() {
347        let policy = sample_policy("deny");
348        let actors = actors_with("bob", &["reviewer"]);
349        let req = AuthzRequest {
350            actor: "bob".into(),
351            action: "deploy".into(),
352            resource: None,
353        };
354        let result = evaluate_authz(&req, &policy, &actors);
355        assert!(!result.allowed);
356        assert!(result.matched_grant.is_none());
357        assert!(result.reason.is_some());
358    }
359
360    #[test]
361    fn test_evaluate_authz_default_deny() {
362        let policy = sample_policy("deny");
363        let actors = actors_with("alice", &["operator"]);
364        let req = AuthzRequest {
365            actor: "alice".into(),
366            action: "unknown_action".into(),
367            resource: None,
368        };
369        let result = evaluate_authz(&req, &policy, &actors);
370        assert!(!result.allowed);
371        assert_eq!(result.policy_default, "deny");
372    }
373
374    #[test]
375    fn test_evaluate_authz_default_allow() {
376        let policy = sample_policy("allow");
377        let actors = actors_with("alice", &["operator"]);
378        let req = AuthzRequest {
379            actor: "alice".into(),
380            action: "unknown_action".into(),
381            resource: None,
382        };
383        let result = evaluate_authz(&req, &policy, &actors);
384        assert!(result.allowed);
385        assert_eq!(result.policy_default, "allow");
386    }
387
388    #[test]
389    fn test_evaluate_authz_wildcard_role() {
390        let policy = sample_policy("deny");
391        let actors = actors_with("charlie", &["intern"]);
392        let req = AuthzRequest {
393            actor: "charlie".into(),
394            action: "read".into(),
395            resource: None,
396        };
397        let result = evaluate_authz(&req, &policy, &actors);
398        assert!(result.allowed, "wildcard '*' should match any role");
399    }
400
401    #[test]
402    fn test_evaluate_authz_unknown_actor() {
403        let policy = sample_policy("deny");
404        let actors = ActorsConfig::default();
405        let req = AuthzRequest {
406            actor: "nobody".into(),
407            action: "deploy".into(),
408            resource: None,
409        };
410        let result = evaluate_authz(&req, &policy, &actors);
411        assert!(!result.allowed);
412        assert!(result.actor_roles.is_empty());
413    }
414
415    #[test]
416    fn test_evaluate_authz_unknown_actor_wildcard_grant() {
417        let policy = sample_policy("deny");
418        // Actor not in actors.yaml but action has wildcard role
419        let actors = ActorsConfig::default();
420        let req = AuthzRequest {
421            actor: "nobody".into(),
422            action: "read".into(),
423            resource: None,
424        };
425        let result = evaluate_authz(&req, &policy, &actors);
426        // Wildcard matches even with empty roles
427        assert!(result.allowed);
428    }
429
430    #[test]
431    fn test_policy_v2_backward_compat() {
432        let yaml = r#"
433version: 2
434roles:
435  - lead
436rules:
437  - id: default
438    when:
439      default: true
440    stages: []
441"#;
442        let config: PolicyV2Config = serde_yaml::from_str(yaml).unwrap();
443        assert_eq!(config.version, 2);
444        assert!(config.permissions.is_none());
445    }
446
447    #[test]
448    fn test_policy_v2_with_permissions() {
449        let yaml = r#"
450version: 2
451roles:
452  - lead
453  - operator
454rules: []
455permissions:
456  default: deny
457  grants:
458    - actions: [deploy]
459      roles: [lead, operator]
460    - actions: [read]
461      roles: ["*"]
462"#;
463        let config: PolicyV2Config = serde_yaml::from_str(yaml).unwrap();
464        assert!(config.permissions.is_some());
465        let perms = config.permissions.unwrap();
466        assert_eq!(perms.default, "deny");
467        assert_eq!(perms.grants.len(), 2);
468        assert_eq!(perms.grants[0].actions, vec!["deploy"]);
469    }
470
471    #[test]
472    fn test_actors_v1_backward_compat() {
473        let yaml = r#"
474version: 1
475actors:
476  alice:
477    roles: [lead, reviewer]
478"#;
479        let cfg: ActorsConfig = serde_yaml::from_str(yaml).unwrap();
480        assert_eq!(cfg.version, 1);
481        let alice = cfg.actors.get("alice").unwrap();
482        assert_eq!(alice.roles, vec!["lead", "reviewer"]);
483        assert_eq!(alice.kind, ActorKind::User); // default
484        assert!(alice.email.is_none());
485        assert!(alice.display_name.is_none());
486        assert!(alice.runtime.is_none());
487    }
488
489    #[test]
490    fn test_actors_v2_full_fields() {
491        let yaml = r#"
492version: 2
493actors:
494  alice:
495    kind: user
496    roles: [lead, reviewer]
497    email: alice@example.com
498    display_name: Alice Chen
499  claude-agent-1:
500    kind: agent
501    roles: [operator]
502    runtime: claude
503"#;
504        let cfg: ActorsConfig = serde_yaml::from_str(yaml).unwrap();
505        assert_eq!(cfg.version, 2);
506
507        let alice = cfg.actors.get("alice").unwrap();
508        assert_eq!(alice.kind, ActorKind::User);
509        assert_eq!(alice.email.as_deref(), Some("alice@example.com"));
510        assert_eq!(alice.display_name.as_deref(), Some("Alice Chen"));
511        assert!(alice.runtime.is_none());
512
513        let agent = cfg.actors.get("claude-agent-1").unwrap();
514        assert_eq!(agent.kind, ActorKind::Agent);
515        assert_eq!(agent.roles, vec!["operator"]);
516        assert_eq!(agent.runtime.as_deref(), Some("claude"));
517        assert!(agent.email.is_none());
518    }
519
520    #[test]
521    fn test_no_permissions_section_defaults_deny() {
522        let policy = PolicyV2Config {
523            version: 2,
524            roles: vec![],
525            rules: vec![],
526            permissions: None,
527        };
528        let actors = actors_with("alice", &["lead"]);
529        let req = AuthzRequest {
530            actor: "alice".into(),
531            action: "deploy".into(),
532            resource: None,
533        };
534        let result = evaluate_authz(&req, &policy, &actors);
535        assert!(!result.allowed);
536        assert!(result
537            .reason
538            .as_ref()
539            .unwrap()
540            .contains("no permissions section"));
541    }
542}