1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::fmt;
3use std::fs;
4use std::path::Path;
5use std::str::FromStr;
6
7use cedar_policy::{
8 Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid, Policy,
9 PolicyId, PolicySet, Request, Schema, ValidationMode, Validator,
10};
11use clap::ValueEnum;
12use color_eyre::eyre::{Result, bail, eyre};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, ValueEnum)]
17#[serde(rename_all = "snake_case")]
18pub enum PolicyAction {
19 Read,
20 Export,
21 Change,
22 SchemaApply,
23 BranchCreate,
24 BranchDelete,
25 BranchMerge,
26 Admin,
42 GraphList,
59}
60
61impl PolicyAction {
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Self::Read => "read",
65 Self::Export => "export",
66 Self::Change => "change",
67 Self::SchemaApply => "schema_apply",
68 Self::BranchCreate => "branch_create",
69 Self::BranchDelete => "branch_delete",
70 Self::BranchMerge => "branch_merge",
71 Self::Admin => "admin",
72 Self::GraphList => "graph_list",
73 }
74 }
75
76 fn uses_branch_scope(self) -> bool {
77 matches!(self, Self::Read | Self::Export | Self::Change)
78 }
79
80 fn uses_target_branch_scope(self) -> bool {
81 matches!(
82 self,
83 Self::BranchCreate | Self::SchemaApply | Self::BranchDelete | Self::BranchMerge
84 )
85 }
86
87 pub fn resource_kind(self) -> PolicyResourceKind {
93 match self {
94 Self::GraphList => PolicyResourceKind::Server,
95 Self::Read
96 | Self::Export
97 | Self::Change
98 | Self::SchemaApply
99 | Self::BranchCreate
100 | Self::BranchDelete
101 | Self::BranchMerge
102 | Self::Admin => PolicyResourceKind::Graph,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, Eq, PartialEq)]
111pub enum PolicyResourceKind {
112 Graph,
114 Server,
116}
117
118#[derive(Debug, Clone, Copy, Eq, PartialEq)]
128pub enum PolicyEngineKind {
129 Graph,
132 Server,
136}
137
138impl fmt::Display for PolicyAction {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 f.write_str(self.as_str())
141 }
142}
143
144impl FromStr for PolicyAction {
145 type Err = color_eyre::eyre::Error;
146
147 fn from_str(value: &str) -> Result<Self> {
148 match value.trim() {
149 "read" => Ok(Self::Read),
150 "export" => Ok(Self::Export),
151 "change" => Ok(Self::Change),
152 "schema_apply" => Ok(Self::SchemaApply),
153 "branch_create" => Ok(Self::BranchCreate),
154 "branch_delete" => Ok(Self::BranchDelete),
155 "branch_merge" => Ok(Self::BranchMerge),
156 "admin" => Ok(Self::Admin),
157 "graph_list" => Ok(Self::GraphList),
158 other => bail!("unknown policy action '{other}'"),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum PolicyBranchScope {
166 Any,
167 Protected,
168 Unprotected,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct PolicyActorSelector {
173 pub group: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct PolicyAllowRule {
178 pub actors: PolicyActorSelector,
179 pub actions: Vec<PolicyAction>,
180 pub branch_scope: Option<PolicyBranchScope>,
181 pub target_branch_scope: Option<PolicyBranchScope>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct PolicyRule {
186 pub id: String,
187 pub allow: PolicyAllowRule,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct PolicyConfig {
192 pub version: u32,
193 #[serde(default)]
194 pub groups: BTreeMap<String, Vec<String>>,
195 #[serde(default)]
196 pub protected_branches: Vec<String>,
197 #[serde(default)]
198 pub rules: Vec<PolicyRule>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct PolicyTestConfig {
203 pub version: u32,
204 #[serde(default)]
205 pub cases: Vec<PolicyTestCase>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PolicyTestCase {
210 pub id: String,
211 pub actor: String,
212 pub action: PolicyAction,
213 pub branch: Option<String>,
214 pub target_branch: Option<String>,
215 pub expect: PolicyExpectation,
216}
217
218#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum PolicyExpectation {
221 Allow,
222 Deny,
223}
224
225#[derive(Debug, Clone)]
234pub struct PolicyRequest {
235 pub action: PolicyAction,
236 pub branch: Option<String>,
237 pub target_branch: Option<String>,
238}
239
240#[derive(Debug, Clone)]
241pub struct PolicyDecision {
242 pub allowed: bool,
243 pub matched_rule_id: Option<String>,
244 pub message: String,
245}
246
247pub struct PolicyCompiler;
248
249#[derive(Clone)]
250pub struct PolicyEngine {
251 graph_id: String,
252 protected_branches: BTreeSet<String>,
253 known_actors: BTreeSet<String>,
254 schema: Schema,
255 entities: Entities,
256 policies: PolicySet,
257 policy_to_rule: HashMap<String, String>,
258}
259
260impl PolicyConfig {
261 pub fn load(path: &Path) -> Result<Self> {
262 let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
263 config.validate()?;
264 Ok(config)
265 }
266
267 pub fn validate(&self) -> Result<()> {
268 if self.version != 1 {
269 bail!("policy version must be 1");
270 }
271
272 for (group, members) in &self.groups {
273 if group.trim().is_empty() {
274 bail!("policy group names must not be blank");
275 }
276 if members.is_empty() {
277 bail!("policy group '{group}' must not be empty");
278 }
279 for actor in members {
280 if actor.trim().is_empty() {
281 bail!("policy group '{group}' contains a blank actor id");
282 }
283 }
284 }
285
286 for branch in &self.protected_branches {
287 if branch.trim().is_empty() {
288 bail!("protected branch names must not be blank");
289 }
290 }
291
292 let mut seen_rule_ids = HashSet::new();
293 for rule in &self.rules {
294 if rule.id.trim().is_empty() {
295 bail!("policy rule ids must not be blank");
296 }
297 if !seen_rule_ids.insert(rule.id.clone()) {
298 bail!("duplicate policy rule id '{}'", rule.id);
299 }
300 if rule.allow.actors.group.trim().is_empty() {
301 bail!("policy rule '{}' must reference a non-blank group", rule.id);
302 }
303 if !self.groups.contains_key(rule.allow.actors.group.as_str()) {
304 bail!(
305 "policy rule '{}' references unknown group '{}'",
306 rule.id,
307 rule.allow.actors.group
308 );
309 }
310 if rule.allow.actions.is_empty() {
311 bail!("policy rule '{}' must include at least one action", rule.id);
312 }
313 if rule.allow.branch_scope.is_some() && rule.allow.target_branch_scope.is_some() {
314 bail!(
315 "policy rule '{}' may specify branch_scope or target_branch_scope, not both",
316 rule.id
317 );
318 }
319 if let Some(_) = rule.allow.branch_scope {
320 for action in &rule.allow.actions {
321 if !action.uses_branch_scope() {
322 bail!(
323 "policy rule '{}' uses branch_scope with unsupported action '{}'",
324 rule.id,
325 action
326 );
327 }
328 }
329 }
330 if let Some(_) = rule.allow.target_branch_scope {
331 for action in &rule.allow.actions {
332 if !action.uses_target_branch_scope() {
333 bail!(
334 "policy rule '{}' uses target_branch_scope with unsupported action '{}'",
335 rule.id,
336 action
337 );
338 }
339 }
340 }
341 let mut server_scoped = false;
346 let mut graph_scoped = false;
347 for action in &rule.allow.actions {
348 match action.resource_kind() {
349 PolicyResourceKind::Server => server_scoped = true,
350 PolicyResourceKind::Graph => graph_scoped = true,
351 }
352 }
353 if server_scoped && graph_scoped {
354 bail!(
355 "policy rule '{}' mixes the server-scoped action `graph_list` \
356 with per-graph actions; split into separate rules",
357 rule.id
358 );
359 }
360 if server_scoped
361 && (rule.allow.branch_scope.is_some() || rule.allow.target_branch_scope.is_some())
362 {
363 bail!(
364 "policy rule '{}' uses branch_scope/target_branch_scope with a \
365 server-scoped action; server-scoped actions have no branch context",
366 rule.id
367 );
368 }
369 }
370
371 Ok(())
372 }
373}
374
375impl PolicyTestConfig {
376 pub fn load(path: &Path) -> Result<Self> {
377 let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
378 if config.version != 1 {
379 bail!("policy test version must be 1");
380 }
381 let mut seen = HashSet::new();
382 for case in &config.cases {
383 if case.id.trim().is_empty() {
384 bail!("policy test case ids must not be blank");
385 }
386 if !seen.insert(case.id.clone()) {
387 bail!("duplicate policy test case id '{}'", case.id);
388 }
389 if case.actor.trim().is_empty() {
390 bail!("policy test case '{}' must not use a blank actor", case.id);
391 }
392 }
393 Ok(config)
394 }
395}
396
397impl PolicyCompiler {
398 pub fn compile(config: &PolicyConfig, graph_id: &str) -> Result<PolicyEngine> {
399 config.validate()?;
400 let (schema, schema_warnings) = Schema::from_cedarschema_str(policy_schema_source())?;
401 let schema_warnings = schema_warnings
402 .map(|warning| warning.to_string())
403 .collect::<Vec<_>>();
404 if !schema_warnings.is_empty() {
405 bail!("policy schema warnings:\n{}", schema_warnings.join("\n"));
406 }
407 let entities = compile_entities(config, graph_id, &schema)?;
408 let (policies, policy_to_rule) = compile_policies(config, graph_id)?;
409 let validator = Validator::new(schema.clone());
410 let validation = validator.validate(&policies, ValidationMode::Strict);
411 let errors = validation
412 .validation_errors()
413 .map(|err| err.to_string())
414 .collect::<Vec<_>>();
415 if !errors.is_empty() {
416 bail!("policy validation failed:\n{}", errors.join("\n"));
417 }
418
419 let known_actors = config
420 .groups
421 .values()
422 .flat_map(|members| members.iter().cloned())
423 .collect();
424 Ok(PolicyEngine {
425 graph_id: graph_id.to_string(),
426 protected_branches: config.protected_branches.iter().cloned().collect(),
427 known_actors,
428 schema,
429 entities,
430 policies,
431 policy_to_rule,
432 })
433 }
434}
435
436impl PolicyEngine {
437 pub fn load_graph(path: &Path, graph_id: &str) -> Result<Self> {
445 let config = PolicyConfig::load(path)?;
446 validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
447 PolicyCompiler::compile(&config, graph_id)
448 }
449
450 pub fn load_server(path: &Path) -> Result<Self> {
456 let config = PolicyConfig::load(path)?;
457 validate_kind_alignment(&config, PolicyEngineKind::Server)?;
458 PolicyCompiler::compile(&config, SERVER_RESOURCE_ID)
464 }
465
466 pub fn authorize(&self, actor_id: &str, request: &PolicyRequest) -> Result<PolicyDecision> {
472 if !self.known_actors.contains(actor_id) {
473 return Ok(self.deny(
474 None,
475 format!(
476 "policy denied action '{}' for unknown actor '{}'",
477 request.action, actor_id
478 ),
479 ));
480 }
481
482 let principal = entity_uid("Actor", actor_id)?;
483 let action = entity_uid("Action", request.action.as_str())?;
484 let resource = match request.action.resource_kind() {
489 PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
490 PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
491 };
492 let context_value = json!({
493 "has_branch": request.branch.is_some(),
494 "branch": request.branch.clone().unwrap_or_default(),
495 "has_target_branch": request.target_branch.is_some(),
496 "target_branch": request.target_branch.clone().unwrap_or_default(),
497 "branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
498 "target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
499 });
500 let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
501 let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
502 let response =
503 Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
504 let errors = response
505 .diagnostics()
506 .errors()
507 .map(|err| err.to_string())
508 .collect::<Vec<_>>();
509 if !errors.is_empty() {
510 bail!("policy evaluation failed:\n{}", errors.join("\n"));
511 }
512
513 let matched_rule_id = response
514 .diagnostics()
515 .reason()
516 .filter_map(|policy_id| {
517 let key: &str = policy_id.as_ref();
518 self.policy_to_rule.get(key).cloned()
519 })
520 .min();
521
522 Ok(match response.decision() {
523 Decision::Allow => PolicyDecision {
524 allowed: true,
525 matched_rule_id: matched_rule_id.clone(),
526 message: format!(
527 "policy allowed action '{}' for actor '{}'",
528 request.action, actor_id
529 ),
530 },
531 Decision::Deny => {
532 let message = format!(
533 "policy denied action '{}'{}{} for actor '{}'",
534 request.action,
535 request
536 .branch
537 .as_deref()
538 .map(|branch| format!(" on branch '{}'", branch))
539 .unwrap_or_default(),
540 request
541 .target_branch
542 .as_deref()
543 .map(|branch| format!(" targeting branch '{}'", branch))
544 .unwrap_or_default(),
545 actor_id
546 );
547 self.deny(matched_rule_id, message)
548 }
549 })
550 }
551
552 pub fn run_tests(&self, tests: &PolicyTestConfig) -> Result<()> {
553 if tests.version != 1 {
554 bail!("policy test version must be 1");
555 }
556 let mut failures = Vec::new();
557 for case in &tests.cases {
558 let decision = self.authorize(
559 &case.actor,
560 &PolicyRequest {
561 action: case.action,
562 branch: case.branch.clone(),
563 target_branch: case.target_branch.clone(),
564 },
565 )?;
566 let expected_allowed = matches!(case.expect, PolicyExpectation::Allow);
567 if decision.allowed != expected_allowed {
568 failures.push(format!(
569 "{}: expected {:?} but got {}",
570 case.id,
571 case.expect,
572 if decision.allowed { "allow" } else { "deny" }
573 ));
574 }
575 }
576 if failures.is_empty() {
577 Ok(())
578 } else {
579 bail!("policy tests failed:\n{}", failures.join("\n"))
580 }
581 }
582
583 pub fn known_actor_count(&self) -> usize {
584 self.known_actors.len()
585 }
586
587 fn deny(&self, matched_rule_id: Option<String>, message: String) -> PolicyDecision {
588 PolicyDecision {
589 allowed: false,
590 matched_rule_id,
591 message,
592 }
593 }
594}
595
596fn validate_kind_alignment(config: &PolicyConfig, kind: PolicyEngineKind) -> Result<()> {
602 let required = match kind {
603 PolicyEngineKind::Graph => PolicyResourceKind::Graph,
604 PolicyEngineKind::Server => PolicyResourceKind::Server,
605 };
606 for rule in &config.rules {
607 for action in &rule.allow.actions {
608 if action.resource_kind() != required {
609 let (got, expected_file) = match action.resource_kind() {
610 PolicyResourceKind::Server => ("server-scoped", "server policy file"),
611 PolicyResourceKind::Graph => ("per-graph", "per-graph policy file"),
612 };
613 bail!(
614 "policy rule '{}' uses {} action '{}' in a {:?} policy file; \
615 move it to a {}",
616 rule.id,
617 got,
618 action,
619 kind,
620 expected_file
621 );
622 }
623 }
624 }
625 Ok(())
626}
627
628fn compile_entities(config: &PolicyConfig, graph_id: &str, schema: &Schema) -> Result<Entities> {
629 let mut group_entities = Vec::new();
630 for group in config.groups.keys() {
631 group_entities.push(Entity::new(
632 entity_uid("Group", group)?,
633 HashMap::new(),
634 HashSet::<EntityUid>::new(),
635 )?);
636 }
637
638 let mut actor_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
639 for (group, members) in &config.groups {
640 for actor in members {
641 actor_groups
642 .entry(actor.clone())
643 .or_default()
644 .insert(group.clone());
645 }
646 }
647
648 let mut actor_entities = Vec::new();
649 for (actor, groups) in actor_groups {
650 let parents = groups
651 .iter()
652 .map(|group| entity_uid("Group", group))
653 .collect::<Result<HashSet<_>>>()?;
654 actor_entities.push(Entity::new(
655 entity_uid("Actor", &actor)?,
656 HashMap::new(),
657 parents,
658 )?);
659 }
660
661 let graph_entity = Entity::new(
662 entity_uid("Graph", graph_id)?,
663 HashMap::new(),
664 HashSet::<EntityUid>::new(),
665 )?;
666
667 let mut entities = Vec::new();
668 entities.extend(group_entities);
669 entities.extend(actor_entities);
670 entities.push(graph_entity);
671
672 let any_server_scoped = config.rules.iter().any(|rule| {
678 rule.allow
679 .actions
680 .iter()
681 .any(|action| action.resource_kind() == PolicyResourceKind::Server)
682 });
683 if any_server_scoped {
684 entities.push(Entity::new(
685 entity_uid("Server", SERVER_RESOURCE_ID)?,
686 HashMap::new(),
687 HashSet::<EntityUid>::new(),
688 )?);
689 }
690
691 Ok(Entities::from_entities(entities, Some(schema))?)
692}
693
694fn compile_policies(
695 config: &PolicyConfig,
696 graph_id: &str,
697) -> Result<(PolicySet, HashMap<String, String>)> {
698 let mut policies = Vec::new();
699 let mut policy_to_rule = HashMap::new();
700
701 for rule in &config.rules {
702 for action in &rule.allow.actions {
703 let policy_id = PolicyId::new(format!("{}:{}", rule.id, action.as_str()));
704 let source = compile_policy_source(rule, action, graph_id);
705 let policy = Policy::parse(Some(policy_id.clone()), source.as_str())?;
706 policy_to_rule.insert(policy_id.to_string(), rule.id.clone());
707 policies.push(policy);
708 }
709 }
710
711 Ok((PolicySet::from_policies(policies)?, policy_to_rule))
712}
713
714fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &str) -> String {
715 let mut conditions = Vec::new();
716 if let Some(scope) = rule.allow.branch_scope {
717 conditions.push(branch_scope_condition(scope));
718 }
719 if let Some(scope) = rule.allow.target_branch_scope {
720 conditions.push(target_branch_scope_condition(scope));
721 }
722
723 let when = if conditions.is_empty() {
724 String::new()
725 } else {
726 format!("\nwhen {{ {} }}", conditions.join(" && "))
727 };
728
729 let resource_literal = match action.resource_kind() {
734 PolicyResourceKind::Graph => {
735 format!("Omnigraph::Graph::{}", cedar_literal(graph_id))
736 }
737 PolicyResourceKind::Server => {
738 format!("Omnigraph::Server::{}", cedar_literal(SERVER_RESOURCE_ID))
739 }
740 };
741
742 format!(
743 r#"permit (
744 principal in Omnigraph::Group::{group},
745 action == Omnigraph::Action::{action},
746 resource == {resource_literal}
747){when};"#,
748 group = cedar_literal(&rule.allow.actors.group),
749 action = cedar_literal(action.as_str()),
750 when = when,
751 resource_literal = resource_literal,
752 )
753}
754
755fn branch_scope_condition(scope: PolicyBranchScope) -> String {
756 match scope {
757 PolicyBranchScope::Any => "true".to_string(),
758 PolicyBranchScope::Protected => {
759 "context.has_branch && context.branch_is_protected".to_string()
760 }
761 PolicyBranchScope::Unprotected => {
762 "context.has_branch && context.branch_is_protected == false".to_string()
763 }
764 }
765}
766
767fn target_branch_scope_condition(scope: PolicyBranchScope) -> String {
768 match scope {
769 PolicyBranchScope::Any => "true".to_string(),
770 PolicyBranchScope::Protected => {
771 "context.has_target_branch && context.target_branch_is_protected".to_string()
772 }
773 PolicyBranchScope::Unprotected => {
774 "context.has_target_branch && context.target_branch_is_protected == false".to_string()
775 }
776 }
777}
778
779fn policy_schema_source() -> &'static str {
780 r#"
786namespace Omnigraph {
787 type RequestContext = {
788 has_branch: Bool,
789 branch: String,
790 has_target_branch: Bool,
791 target_branch: String,
792 branch_is_protected: Bool,
793 target_branch_is_protected: Bool,
794 };
795
796 entity Actor in [Group];
797 entity Group;
798 entity Graph;
799 entity Server;
800
801 action "read" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
802 action "export" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
803 action "change" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
804 action "schema_apply" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
805 action "branch_create" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
806 action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
807 action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
808 action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
809
810 action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext };
811}
812"#
813}
814
815const SERVER_RESOURCE_ID: &str = "root";
819
820fn entity_uid(entity_type: &str, id: &str) -> Result<EntityUid> {
821 let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?;
822 let entity_id = EntityId::from_str(id).map_err(|err| eyre!(err.to_string()))?;
823 Ok(EntityUid::from_type_name_and_id(typename, entity_id))
824}
825
826fn cedar_literal(value: &str) -> String {
827 serde_json::to_string(value).expect("string literal should serialize")
828}
829
830impl PolicyRequest {
831 pub fn action(&self) -> PolicyAction {
832 self.action
833 }
834
835 pub fn branch(&self) -> Option<&str> {
836 self.branch.as_deref()
837 }
838
839 pub fn target_branch(&self) -> Option<&str> {
840 self.target_branch.as_deref()
841 }
842}
843
844#[derive(Debug, Clone, Eq, PartialEq)]
877pub enum ResourceScope {
878 Graph,
882 Branch(String),
886 TargetBranch(String),
892 BranchTransition { source: String, target: String },
898}
899
900impl ResourceScope {
901 pub fn to_branch_pair(&self) -> (Option<&str>, Option<&str>) {
906 match self {
907 ResourceScope::Graph => (None, None),
908 ResourceScope::Branch(branch) => (Some(branch.as_str()), None),
909 ResourceScope::TargetBranch(target) => (None, Some(target.as_str())),
910 ResourceScope::BranchTransition { source, target } => {
911 (Some(source.as_str()), Some(target.as_str()))
912 }
913 }
914 }
915}
916
917#[derive(Debug, Clone)]
921pub enum PolicyError {
922 Denied(String),
924 Internal(String),
927}
928
929impl fmt::Display for PolicyError {
930 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
931 match self {
932 PolicyError::Denied(msg) => write!(f, "policy denied: {msg}"),
933 PolicyError::Internal(msg) => write!(f, "policy evaluation failed: {msg}"),
934 }
935 }
936}
937
938impl std::error::Error for PolicyError {}
939
940pub trait PolicyChecker: Send + Sync {
949 fn check(
953 &self,
954 action: PolicyAction,
955 scope: &ResourceScope,
956 actor: &str,
957 ) -> Result<(), PolicyError>;
958}
959
960impl PolicyChecker for PolicyEngine {
961 fn check(
962 &self,
963 action: PolicyAction,
964 scope: &ResourceScope,
965 actor: &str,
966 ) -> Result<(), PolicyError> {
967 let (branch, target_branch) = scope.to_branch_pair();
968 let request = PolicyRequest {
969 action,
970 branch: branch.map(|s| s.to_string()),
971 target_branch: target_branch.map(|s| s.to_string()),
972 };
973 let decision = self
974 .authorize(actor, &request)
975 .map_err(|e| PolicyError::Internal(e.to_string()))?;
976 if decision.allowed {
977 Ok(())
978 } else {
979 Err(PolicyError::Denied(decision.message))
980 }
981 }
982}
983
984#[cfg(test)]
985mod tests {
986 use super::{
987 PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest,
988 PolicyTestCase, PolicyTestConfig,
989 };
990
991 #[test]
992 fn rejects_duplicate_rule_ids() {
993 let policy: PolicyConfig = serde_yaml::from_str(
994 r#"
995version: 1
996groups:
997 team: [act-andrew]
998rules:
999 - id: same
1000 allow:
1001 actors: { group: team }
1002 actions: [read]
1003 branch_scope: any
1004 - id: same
1005 allow:
1006 actors: { group: team }
1007 actions: [export]
1008 branch_scope: any
1009"#,
1010 )
1011 .unwrap();
1012
1013 let err = policy.validate().unwrap_err();
1014 assert!(err.to_string().contains("duplicate policy rule id"));
1015 }
1016
1017 #[test]
1018 fn rejects_unknown_group_references() {
1019 let policy: PolicyConfig = serde_yaml::from_str(
1020 r#"
1021version: 1
1022groups:
1023 team: [act-andrew]
1024rules:
1025 - id: bad
1026 allow:
1027 actors: { group: admins }
1028 actions: [read]
1029 branch_scope: any
1030"#,
1031 )
1032 .unwrap();
1033
1034 let err = policy.validate().unwrap_err();
1035 assert!(err.to_string().contains("references unknown group"));
1036 }
1037
1038 #[test]
1039 fn rejects_invalid_scope_action_combinations() {
1040 let policy: PolicyConfig = serde_yaml::from_str(
1041 r#"
1042version: 1
1043groups:
1044 team: [act-andrew]
1045rules:
1046 - id: bad
1047 allow:
1048 actors: { group: team }
1049 actions: [branch_merge]
1050 branch_scope: protected
1051"#,
1052 )
1053 .unwrap();
1054
1055 let err = policy.validate().unwrap_err();
1056 assert!(err.to_string().contains("unsupported action"));
1057 }
1058
1059 #[test]
1060 fn compiles_and_authorizes_branch_and_target_rules() {
1061 let policy: PolicyConfig = serde_yaml::from_str(
1062 r#"
1063version: 1
1064groups:
1065 team: [act-andrew, act-bruno]
1066 admins: [act-andrew]
1067protected_branches: [main]
1068rules:
1069 - id: team-read
1070 allow:
1071 actors: { group: team }
1072 actions: [read, export]
1073 branch_scope: any
1074 - id: team-write
1075 allow:
1076 actors: { group: team }
1077 actions: [change]
1078 branch_scope: unprotected
1079 - id: admins-promote
1080 allow:
1081 actors: { group: admins }
1082 actions: [branch_delete, branch_merge]
1083 target_branch_scope: protected
1084"#,
1085 )
1086 .unwrap();
1087
1088 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1089 let allow = engine
1090 .authorize(
1091 "act-bruno",
1092 &PolicyRequest {
1093 action: PolicyAction::Change,
1094 branch: Some("feature".to_string()),
1095 target_branch: None,
1096 },
1097 )
1098 .unwrap();
1099 assert!(allow.allowed);
1100 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-write"));
1101
1102 let deny = engine
1103 .authorize(
1104 "act-bruno",
1105 &PolicyRequest {
1106 action: PolicyAction::BranchDelete,
1107 branch: None,
1108 target_branch: Some("main".to_string()),
1109 },
1110 )
1111 .unwrap();
1112 assert!(!deny.allowed);
1113
1114 let admin = engine
1115 .authorize(
1116 "act-andrew",
1117 &PolicyRequest {
1118 action: PolicyAction::BranchDelete,
1119 branch: None,
1120 target_branch: Some("main".to_string()),
1121 },
1122 )
1123 .unwrap();
1124 assert!(admin.allowed);
1125 assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
1126 }
1127
1128 #[test]
1129 fn policy_tests_enforce_expected_outcomes() {
1130 let policy: PolicyConfig = serde_yaml::from_str(
1131 r#"
1132version: 1
1133groups:
1134 team: [act-andrew]
1135protected_branches: [main]
1136rules:
1137 - id: team-read
1138 allow:
1139 actors: { group: team }
1140 actions: [read]
1141 branch_scope: any
1142"#,
1143 )
1144 .unwrap();
1145 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1146 let tests = PolicyTestConfig {
1147 version: 1,
1148 cases: vec![
1149 PolicyTestCase {
1150 id: "allow-read".to_string(),
1151 actor: "act-andrew".to_string(),
1152 action: PolicyAction::Read,
1153 branch: Some("main".to_string()),
1154 target_branch: None,
1155 expect: PolicyExpectation::Allow,
1156 },
1157 PolicyTestCase {
1158 id: "deny-change".to_string(),
1159 actor: "act-andrew".to_string(),
1160 action: PolicyAction::Change,
1161 branch: Some("main".to_string()),
1162 target_branch: None,
1163 expect: PolicyExpectation::Deny,
1164 },
1165 ],
1166 };
1167
1168 engine.run_tests(&tests).unwrap();
1169 }
1170
1171 #[test]
1172 fn schema_apply_uses_target_branch_scope() {
1173 let policy: PolicyConfig = serde_yaml::from_str(
1174 r#"
1175version: 1
1176groups:
1177 admins: [act-ragnor]
1178protected_branches: [main]
1179rules:
1180 - id: admins-schema-apply
1181 allow:
1182 actors: { group: admins }
1183 actions: [schema_apply]
1184 target_branch_scope: protected
1185"#,
1186 )
1187 .unwrap();
1188
1189 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1190 let allow = engine
1191 .authorize(
1192 "act-ragnor",
1193 &PolicyRequest {
1194 action: PolicyAction::SchemaApply,
1195 branch: None,
1196 target_branch: Some("main".to_string()),
1197 },
1198 )
1199 .unwrap();
1200 assert!(allow.allowed);
1201
1202 let deny = engine
1203 .authorize(
1204 "act-ragnor",
1205 &PolicyRequest {
1206 action: PolicyAction::SchemaApply,
1207 branch: None,
1208 target_branch: Some("feature".to_string()),
1209 },
1210 )
1211 .unwrap();
1212 assert!(!deny.allowed);
1213 }
1214
1215 #[test]
1218 fn graph_list_action_authorizes_against_server_resource() {
1219 let policy: PolicyConfig = serde_yaml::from_str(
1220 r#"
1221version: 1
1222groups:
1223 admins: [act-andrew]
1224 viewers: [act-bruno]
1225rules:
1226 - id: admins-list-graphs
1227 allow:
1228 actors: { group: admins }
1229 actions: [graph_list]
1230"#,
1231 )
1232 .unwrap();
1233
1234 let engine = PolicyCompiler::compile(&policy, "ignored").unwrap();
1239
1240 let allow = engine
1241 .authorize(
1242 "act-andrew",
1243 &PolicyRequest {
1244 action: PolicyAction::GraphList,
1245 branch: None,
1246 target_branch: None,
1247 },
1248 )
1249 .unwrap();
1250 assert!(allow.allowed);
1251 assert_eq!(allow.matched_rule_id.as_deref(), Some("admins-list-graphs"));
1252
1253 let deny = engine
1255 .authorize(
1256 "act-bruno",
1257 &PolicyRequest {
1258 action: PolicyAction::GraphList,
1259 branch: None,
1260 target_branch: None,
1261 },
1262 )
1263 .unwrap();
1264 assert!(!deny.allowed);
1265 }
1266
1267 #[test]
1268 fn server_scoped_rule_cannot_use_branch_scope() {
1269 let policy: PolicyConfig = serde_yaml::from_str(
1270 r#"
1271version: 1
1272groups:
1273 admins: [act-andrew]
1274rules:
1275 - id: bad-branch-scope-on-graph-list
1276 allow:
1277 actors: { group: admins }
1278 actions: [graph_list]
1279 branch_scope: any
1280"#,
1281 )
1282 .unwrap();
1283 let err = policy.validate().unwrap_err();
1284 let msg = err.to_string();
1285 assert!(
1286 msg.contains("branch_scope") || msg.contains("server-scoped"),
1287 "expected branch_scope rejection for server-scoped action; got: {msg}"
1288 );
1289 }
1290
1291 #[test]
1292 fn rule_mixing_server_and_per_graph_actions_is_rejected() {
1293 let policy: PolicyConfig = serde_yaml::from_str(
1297 r#"
1298version: 1
1299groups:
1300 admins: [act-andrew]
1301rules:
1302 - id: mixed-resource-kinds
1303 allow:
1304 actors: { group: admins }
1305 actions: [graph_list, read]
1306"#,
1307 )
1308 .unwrap();
1309 let err = policy.validate().unwrap_err();
1310 let msg = err.to_string();
1311 assert!(
1312 msg.contains("server-scoped") || msg.contains("split into separate rules"),
1313 "expected mix-resource-kinds rejection; got: {msg}"
1314 );
1315 }
1316
1317 #[test]
1318 fn per_graph_rules_continue_to_work_alongside_server_rules() {
1319 let policy: PolicyConfig = serde_yaml::from_str(
1325 r#"
1326version: 1
1327groups:
1328 team: [act-andrew]
1329protected_branches: [main]
1330rules:
1331 - id: team-read
1332 allow:
1333 actors: { group: team }
1334 actions: [read, export]
1335 branch_scope: any
1336"#,
1337 )
1338 .unwrap();
1339 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1340 let allow = engine
1341 .authorize(
1342 "act-andrew",
1343 &PolicyRequest {
1344 action: PolicyAction::Read,
1345 branch: Some("main".to_string()),
1346 target_branch: None,
1347 },
1348 )
1349 .unwrap();
1350 assert!(allow.allowed);
1351 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-read"));
1352 }
1353
1354 #[test]
1362 fn load_graph_rejects_server_scoped_action() {
1363 let dir = tempfile::tempdir().unwrap();
1364 let path = dir.path().join("bad-graph-policy.yaml");
1365 std::fs::write(
1366 &path,
1367 r#"
1368version: 1
1369groups:
1370 admins: [act-andrew]
1371rules:
1372 - id: misplaced-graph-list
1373 allow:
1374 actors: { group: admins }
1375 actions: [graph_list]
1376"#,
1377 )
1378 .unwrap();
1379 let err = match PolicyEngine::load_graph(&path, "g1") {
1380 Ok(_) => panic!("expected server-scoped action in per-graph file to be rejected"),
1381 Err(e) => e,
1382 };
1383 let msg = err.to_string();
1384 assert!(
1385 msg.contains("server-scoped") && msg.contains("graph_list"),
1386 "expected server-scoped-in-graph-file rejection, got: {msg}"
1387 );
1388 }
1389
1390 #[test]
1394 fn load_server_rejects_per_graph_action() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let path = dir.path().join("bad-server-policy.yaml");
1397 std::fs::write(
1398 &path,
1399 r#"
1400version: 1
1401groups:
1402 team: [act-andrew]
1403rules:
1404 - id: misplaced-read
1405 allow:
1406 actors: { group: team }
1407 actions: [read]
1408 branch_scope: any
1409"#,
1410 )
1411 .unwrap();
1412 let err = match PolicyEngine::load_server(&path) {
1413 Ok(_) => panic!("expected per-graph action in server file to be rejected"),
1414 Err(e) => e,
1415 };
1416 let msg = err.to_string();
1417 assert!(
1418 msg.contains("per-graph") && msg.contains("read"),
1419 "expected per-graph-in-server-file rejection, got: {msg}"
1420 );
1421 }
1422
1423 #[test]
1427 fn load_graph_accepts_per_graph_only_policy() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let path = dir.path().join("ok-graph-policy.yaml");
1430 std::fs::write(
1431 &path,
1432 r#"
1433version: 1
1434groups:
1435 team: [act-andrew]
1436rules:
1437 - id: team-read
1438 allow:
1439 actors: { group: team }
1440 actions: [read]
1441 branch_scope: any
1442"#,
1443 )
1444 .unwrap();
1445 let engine = PolicyEngine::load_graph(&path, "g1").unwrap();
1446 let decision = engine
1447 .authorize(
1448 "act-andrew",
1449 &PolicyRequest {
1450 action: PolicyAction::Read,
1451 branch: Some("main".to_string()),
1452 target_branch: None,
1453 },
1454 )
1455 .unwrap();
1456 assert!(decision.allowed);
1457 }
1458
1459 #[test]
1462 fn load_server_accepts_server_only_policy() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let path = dir.path().join("ok-server-policy.yaml");
1465 std::fs::write(
1466 &path,
1467 r#"
1468version: 1
1469groups:
1470 admins: [act-andrew]
1471rules:
1472 - id: admins-list-graphs
1473 allow:
1474 actors: { group: admins }
1475 actions: [graph_list]
1476"#,
1477 )
1478 .unwrap();
1479 let engine = PolicyEngine::load_server(&path).unwrap();
1480 let decision = engine
1481 .authorize(
1482 "act-andrew",
1483 &PolicyRequest {
1484 action: PolicyAction::GraphList,
1485 branch: None,
1486 target_branch: None,
1487 },
1488 )
1489 .unwrap();
1490 assert!(decision.allowed);
1491 }
1492}