1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::path::Path;
8
9#[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 #[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#[derive(Debug, Serialize, Deserialize, Clone)]
64pub struct ActorsConfig {
65 pub version: u32,
66 #[serde(default)]
67 pub actors: BTreeMap<String, ActorDef>,
68}
69
70#[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PermissionsConfig {
128 #[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#[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
165pub 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 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 for grant in &permissions.grants {
203 if !grant.actions.contains(&req.action) {
204 continue;
205 }
206 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 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
242pub 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
267pub 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
278pub 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 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 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); 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}