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 InvokeQuery,
74}
75
76impl PolicyAction {
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Read => "read",
80 Self::Export => "export",
81 Self::Change => "change",
82 Self::SchemaApply => "schema_apply",
83 Self::BranchCreate => "branch_create",
84 Self::BranchDelete => "branch_delete",
85 Self::BranchMerge => "branch_merge",
86 Self::Admin => "admin",
87 Self::GraphList => "graph_list",
88 Self::InvokeQuery => "invoke_query",
89 }
90 }
91
92 fn uses_branch_scope(self) -> bool {
93 matches!(self, Self::Read | Self::Export | Self::Change)
94 }
95
96 fn uses_target_branch_scope(self) -> bool {
97 matches!(
98 self,
99 Self::BranchCreate | Self::SchemaApply | Self::BranchDelete | Self::BranchMerge
100 )
101 }
102
103 pub fn resource_kind(self) -> PolicyResourceKind {
109 match self {
110 Self::GraphList => PolicyResourceKind::Server,
111 Self::Read
112 | Self::Export
113 | Self::Change
114 | Self::SchemaApply
115 | Self::BranchCreate
116 | Self::BranchDelete
117 | Self::BranchMerge
118 | Self::Admin
119 | Self::InvokeQuery => PolicyResourceKind::Graph,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Copy, Eq, PartialEq)]
128pub enum PolicyResourceKind {
129 Graph,
131 Server,
133}
134
135#[derive(Debug, Clone, Copy, Eq, PartialEq)]
145pub enum PolicyEngineKind {
146 Graph,
149 Server,
153}
154
155impl fmt::Display for PolicyAction {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 f.write_str(self.as_str())
158 }
159}
160
161impl FromStr for PolicyAction {
162 type Err = color_eyre::eyre::Error;
163
164 fn from_str(value: &str) -> Result<Self> {
165 match value.trim() {
166 "read" => Ok(Self::Read),
167 "export" => Ok(Self::Export),
168 "change" => Ok(Self::Change),
169 "schema_apply" => Ok(Self::SchemaApply),
170 "branch_create" => Ok(Self::BranchCreate),
171 "branch_delete" => Ok(Self::BranchDelete),
172 "branch_merge" => Ok(Self::BranchMerge),
173 "admin" => Ok(Self::Admin),
174 "graph_list" => Ok(Self::GraphList),
175 "invoke_query" => Ok(Self::InvokeQuery),
176 other => bail!("unknown policy action '{other}'"),
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum PolicyBranchScope {
184 Any,
185 Protected,
186 Unprotected,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct PolicyActorSelector {
191 pub group: String,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PolicyAllowRule {
196 pub actors: PolicyActorSelector,
197 pub actions: Vec<PolicyAction>,
198 pub branch_scope: Option<PolicyBranchScope>,
199 pub target_branch_scope: Option<PolicyBranchScope>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PolicyRule {
204 pub id: String,
205 pub allow: PolicyAllowRule,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PolicyConfig {
210 pub version: u32,
211 #[serde(default)]
212 pub groups: BTreeMap<String, Vec<String>>,
213 #[serde(default)]
214 pub protected_branches: Vec<String>,
215 #[serde(default)]
216 pub rules: Vec<PolicyRule>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct PolicyTestConfig {
221 pub version: u32,
222 #[serde(default)]
223 pub cases: Vec<PolicyTestCase>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct PolicyTestCase {
228 pub id: String,
229 pub actor: String,
230 pub action: PolicyAction,
231 pub branch: Option<String>,
232 pub target_branch: Option<String>,
233 pub expect: PolicyExpectation,
234}
235
236#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum PolicyExpectation {
239 Allow,
240 Deny,
241}
242
243#[derive(Debug, Clone)]
252pub struct PolicyRequest {
253 pub action: PolicyAction,
254 pub branch: Option<String>,
255 pub target_branch: Option<String>,
256}
257
258#[derive(Debug, Clone)]
259pub struct PolicyDecision {
260 pub allowed: bool,
261 pub matched_rule_id: Option<String>,
262 pub message: String,
263}
264
265pub struct PolicyCompiler;
266
267#[derive(Clone)]
268pub struct PolicyEngine {
269 graph_id: String,
270 protected_branches: BTreeSet<String>,
271 known_actors: BTreeSet<String>,
272 schema: Schema,
273 entities: Entities,
274 policies: PolicySet,
275 policy_to_rule: HashMap<String, String>,
276}
277
278impl PolicyConfig {
279 pub fn load(path: &Path) -> Result<Self> {
280 let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
281 config.validate()?;
282 Ok(config)
283 }
284
285 pub fn validate(&self) -> Result<()> {
286 if self.version != 1 {
287 bail!("policy version must be 1");
288 }
289
290 for (group, members) in &self.groups {
291 if group.trim().is_empty() {
292 bail!("policy group names must not be blank");
293 }
294 if members.is_empty() {
295 bail!("policy group '{group}' must not be empty");
296 }
297 for actor in members {
298 if actor.trim().is_empty() {
299 bail!("policy group '{group}' contains a blank actor id");
300 }
301 }
302 }
303
304 for branch in &self.protected_branches {
305 if branch.trim().is_empty() {
306 bail!("protected branch names must not be blank");
307 }
308 }
309
310 let mut seen_rule_ids = HashSet::new();
311 for rule in &self.rules {
312 if rule.id.trim().is_empty() {
313 bail!("policy rule ids must not be blank");
314 }
315 if !seen_rule_ids.insert(rule.id.clone()) {
316 bail!("duplicate policy rule id '{}'", rule.id);
317 }
318 if rule.allow.actors.group.trim().is_empty() {
319 bail!("policy rule '{}' must reference a non-blank group", rule.id);
320 }
321 if !self.groups.contains_key(rule.allow.actors.group.as_str()) {
322 bail!(
323 "policy rule '{}' references unknown group '{}'",
324 rule.id,
325 rule.allow.actors.group
326 );
327 }
328 if rule.allow.actions.is_empty() {
329 bail!("policy rule '{}' must include at least one action", rule.id);
330 }
331 if rule.allow.branch_scope.is_some() && rule.allow.target_branch_scope.is_some() {
332 bail!(
333 "policy rule '{}' may specify branch_scope or target_branch_scope, not both",
334 rule.id
335 );
336 }
337 if let Some(_) = rule.allow.branch_scope {
338 for action in &rule.allow.actions {
339 if !action.uses_branch_scope() {
340 bail!(
341 "policy rule '{}' uses branch_scope with unsupported action '{}'",
342 rule.id,
343 action
344 );
345 }
346 }
347 }
348 if let Some(_) = rule.allow.target_branch_scope {
349 for action in &rule.allow.actions {
350 if !action.uses_target_branch_scope() {
351 bail!(
352 "policy rule '{}' uses target_branch_scope with unsupported action '{}'",
353 rule.id,
354 action
355 );
356 }
357 }
358 }
359 let mut server_scoped = false;
364 let mut graph_scoped = false;
365 for action in &rule.allow.actions {
366 match action.resource_kind() {
367 PolicyResourceKind::Server => server_scoped = true,
368 PolicyResourceKind::Graph => graph_scoped = true,
369 }
370 }
371 if server_scoped && graph_scoped {
372 bail!(
373 "policy rule '{}' mixes the server-scoped action `graph_list` \
374 with per-graph actions; split into separate rules",
375 rule.id
376 );
377 }
378 if server_scoped
379 && (rule.allow.branch_scope.is_some() || rule.allow.target_branch_scope.is_some())
380 {
381 bail!(
382 "policy rule '{}' uses branch_scope/target_branch_scope with a \
383 server-scoped action; server-scoped actions have no branch context",
384 rule.id
385 );
386 }
387 }
388
389 Ok(())
390 }
391}
392
393impl PolicyTestConfig {
394 pub fn load(path: &Path) -> Result<Self> {
395 let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
396 if config.version != 1 {
397 bail!("policy test version must be 1");
398 }
399 let mut seen = HashSet::new();
400 for case in &config.cases {
401 if case.id.trim().is_empty() {
402 bail!("policy test case ids must not be blank");
403 }
404 if !seen.insert(case.id.clone()) {
405 bail!("duplicate policy test case id '{}'", case.id);
406 }
407 if case.actor.trim().is_empty() {
408 bail!("policy test case '{}' must not use a blank actor", case.id);
409 }
410 }
411 Ok(config)
412 }
413}
414
415impl PolicyCompiler {
416 pub fn compile(config: &PolicyConfig, graph_id: &str) -> Result<PolicyEngine> {
417 config.validate()?;
418 let (schema, schema_warnings) = Schema::from_cedarschema_str(policy_schema_source())?;
419 let schema_warnings = schema_warnings
420 .map(|warning| warning.to_string())
421 .collect::<Vec<_>>();
422 if !schema_warnings.is_empty() {
423 bail!("policy schema warnings:\n{}", schema_warnings.join("\n"));
424 }
425 let entities = compile_entities(config, graph_id, &schema)?;
426 let (policies, policy_to_rule) = compile_policies(config, graph_id)?;
427 let validator = Validator::new(schema.clone());
428 let validation = validator.validate(&policies, ValidationMode::Strict);
429 let errors = validation
430 .validation_errors()
431 .map(|err| err.to_string())
432 .collect::<Vec<_>>();
433 if !errors.is_empty() {
434 bail!("policy validation failed:\n{}", errors.join("\n"));
435 }
436
437 let known_actors = config
438 .groups
439 .values()
440 .flat_map(|members| members.iter().cloned())
441 .collect();
442 Ok(PolicyEngine {
443 graph_id: graph_id.to_string(),
444 protected_branches: config.protected_branches.iter().cloned().collect(),
445 known_actors,
446 schema,
447 entities,
448 policies,
449 policy_to_rule,
450 })
451 }
452}
453
454impl PolicyEngine {
455 pub fn load_graph(path: &Path, graph_id: &str) -> Result<Self> {
463 let config = PolicyConfig::load(path)?;
464 validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
465 PolicyCompiler::compile(&config, graph_id)
466 }
467
468 pub fn load_server(path: &Path) -> Result<Self> {
474 let config = PolicyConfig::load(path)?;
475 validate_kind_alignment(&config, PolicyEngineKind::Server)?;
476 PolicyCompiler::compile(&config, SERVER_RESOURCE_ID)
482 }
483
484 pub fn authorize(&self, actor_id: &str, request: &PolicyRequest) -> Result<PolicyDecision> {
490 if !self.known_actors.contains(actor_id) {
491 return Ok(self.deny(
492 None,
493 format!(
494 "policy denied action '{}' for unknown actor '{}'",
495 request.action, actor_id
496 ),
497 ));
498 }
499
500 let principal = entity_uid("Actor", actor_id)?;
501 let action = entity_uid("Action", request.action.as_str())?;
502 let resource = match request.action.resource_kind() {
507 PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
508 PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
509 };
510 let context_value = json!({
511 "has_branch": request.branch.is_some(),
512 "branch": request.branch.clone().unwrap_or_default(),
513 "has_target_branch": request.target_branch.is_some(),
514 "target_branch": request.target_branch.clone().unwrap_or_default(),
515 "branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
516 "target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
517 });
518 let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
519 let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
520 let response =
521 Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
522 let errors = response
523 .diagnostics()
524 .errors()
525 .map(|err| err.to_string())
526 .collect::<Vec<_>>();
527 if !errors.is_empty() {
528 bail!("policy evaluation failed:\n{}", errors.join("\n"));
529 }
530
531 let matched_rule_id = response
532 .diagnostics()
533 .reason()
534 .filter_map(|policy_id| {
535 let key: &str = policy_id.as_ref();
536 self.policy_to_rule.get(key).cloned()
537 })
538 .min();
539
540 Ok(match response.decision() {
541 Decision::Allow => PolicyDecision {
542 allowed: true,
543 matched_rule_id: matched_rule_id.clone(),
544 message: format!(
545 "policy allowed action '{}' for actor '{}'",
546 request.action, actor_id
547 ),
548 },
549 Decision::Deny => {
550 let message = format!(
551 "policy denied action '{}'{}{} for actor '{}'",
552 request.action,
553 request
554 .branch
555 .as_deref()
556 .map(|branch| format!(" on branch '{}'", branch))
557 .unwrap_or_default(),
558 request
559 .target_branch
560 .as_deref()
561 .map(|branch| format!(" targeting branch '{}'", branch))
562 .unwrap_or_default(),
563 actor_id
564 );
565 self.deny(matched_rule_id, message)
566 }
567 })
568 }
569
570 pub fn run_tests(&self, tests: &PolicyTestConfig) -> Result<()> {
571 if tests.version != 1 {
572 bail!("policy test version must be 1");
573 }
574 let mut failures = Vec::new();
575 for case in &tests.cases {
576 let decision = self.authorize(
577 &case.actor,
578 &PolicyRequest {
579 action: case.action,
580 branch: case.branch.clone(),
581 target_branch: case.target_branch.clone(),
582 },
583 )?;
584 let expected_allowed = matches!(case.expect, PolicyExpectation::Allow);
585 if decision.allowed != expected_allowed {
586 failures.push(format!(
587 "{}: expected {:?} but got {}",
588 case.id,
589 case.expect,
590 if decision.allowed { "allow" } else { "deny" }
591 ));
592 }
593 }
594 if failures.is_empty() {
595 Ok(())
596 } else {
597 bail!("policy tests failed:\n{}", failures.join("\n"))
598 }
599 }
600
601 pub fn known_actor_count(&self) -> usize {
602 self.known_actors.len()
603 }
604
605 fn deny(&self, matched_rule_id: Option<String>, message: String) -> PolicyDecision {
606 PolicyDecision {
607 allowed: false,
608 matched_rule_id,
609 message,
610 }
611 }
612}
613
614fn validate_kind_alignment(config: &PolicyConfig, kind: PolicyEngineKind) -> Result<()> {
620 let required = match kind {
621 PolicyEngineKind::Graph => PolicyResourceKind::Graph,
622 PolicyEngineKind::Server => PolicyResourceKind::Server,
623 };
624 for rule in &config.rules {
625 for action in &rule.allow.actions {
626 if action.resource_kind() != required {
627 let (got, expected_file) = match action.resource_kind() {
628 PolicyResourceKind::Server => ("server-scoped", "server policy file"),
629 PolicyResourceKind::Graph => ("per-graph", "per-graph policy file"),
630 };
631 bail!(
632 "policy rule '{}' uses {} action '{}' in a {:?} policy file; \
633 move it to a {}",
634 rule.id,
635 got,
636 action,
637 kind,
638 expected_file
639 );
640 }
641 }
642 }
643 Ok(())
644}
645
646fn compile_entities(config: &PolicyConfig, graph_id: &str, schema: &Schema) -> Result<Entities> {
647 let mut group_entities = Vec::new();
648 for group in config.groups.keys() {
649 group_entities.push(Entity::new(
650 entity_uid("Group", group)?,
651 HashMap::new(),
652 HashSet::<EntityUid>::new(),
653 )?);
654 }
655
656 let mut actor_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
657 for (group, members) in &config.groups {
658 for actor in members {
659 actor_groups
660 .entry(actor.clone())
661 .or_default()
662 .insert(group.clone());
663 }
664 }
665
666 let mut actor_entities = Vec::new();
667 for (actor, groups) in actor_groups {
668 let parents = groups
669 .iter()
670 .map(|group| entity_uid("Group", group))
671 .collect::<Result<HashSet<_>>>()?;
672 actor_entities.push(Entity::new(
673 entity_uid("Actor", &actor)?,
674 HashMap::new(),
675 parents,
676 )?);
677 }
678
679 let graph_entity = Entity::new(
680 entity_uid("Graph", graph_id)?,
681 HashMap::new(),
682 HashSet::<EntityUid>::new(),
683 )?;
684
685 let mut entities = Vec::new();
686 entities.extend(group_entities);
687 entities.extend(actor_entities);
688 entities.push(graph_entity);
689
690 let any_server_scoped = config.rules.iter().any(|rule| {
696 rule.allow
697 .actions
698 .iter()
699 .any(|action| action.resource_kind() == PolicyResourceKind::Server)
700 });
701 if any_server_scoped {
702 entities.push(Entity::new(
703 entity_uid("Server", SERVER_RESOURCE_ID)?,
704 HashMap::new(),
705 HashSet::<EntityUid>::new(),
706 )?);
707 }
708
709 Ok(Entities::from_entities(entities, Some(schema))?)
710}
711
712fn compile_policies(
713 config: &PolicyConfig,
714 graph_id: &str,
715) -> Result<(PolicySet, HashMap<String, String>)> {
716 let mut policies = Vec::new();
717 let mut policy_to_rule = HashMap::new();
718
719 for rule in &config.rules {
720 for action in &rule.allow.actions {
721 let policy_id = PolicyId::new(format!("{}:{}", rule.id, action.as_str()));
722 let source = compile_policy_source(rule, action, graph_id);
723 let policy = Policy::parse(Some(policy_id.clone()), source.as_str())?;
724 policy_to_rule.insert(policy_id.to_string(), rule.id.clone());
725 policies.push(policy);
726 }
727 }
728
729 Ok((PolicySet::from_policies(policies)?, policy_to_rule))
730}
731
732fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &str) -> String {
733 let mut conditions = Vec::new();
734 if let Some(scope) = rule.allow.branch_scope {
735 conditions.push(branch_scope_condition(scope));
736 }
737 if let Some(scope) = rule.allow.target_branch_scope {
738 conditions.push(target_branch_scope_condition(scope));
739 }
740
741 let when = if conditions.is_empty() {
742 String::new()
743 } else {
744 format!("\nwhen {{ {} }}", conditions.join(" && "))
745 };
746
747 let resource_literal = match action.resource_kind() {
752 PolicyResourceKind::Graph => {
753 format!("Omnigraph::Graph::{}", cedar_literal(graph_id))
754 }
755 PolicyResourceKind::Server => {
756 format!("Omnigraph::Server::{}", cedar_literal(SERVER_RESOURCE_ID))
757 }
758 };
759
760 format!(
761 r#"permit (
762 principal in Omnigraph::Group::{group},
763 action == Omnigraph::Action::{action},
764 resource == {resource_literal}
765){when};"#,
766 group = cedar_literal(&rule.allow.actors.group),
767 action = cedar_literal(action.as_str()),
768 when = when,
769 resource_literal = resource_literal,
770 )
771}
772
773fn branch_scope_condition(scope: PolicyBranchScope) -> String {
774 match scope {
775 PolicyBranchScope::Any => "true".to_string(),
776 PolicyBranchScope::Protected => {
777 "context.has_branch && context.branch_is_protected".to_string()
778 }
779 PolicyBranchScope::Unprotected => {
780 "context.has_branch && context.branch_is_protected == false".to_string()
781 }
782 }
783}
784
785fn target_branch_scope_condition(scope: PolicyBranchScope) -> String {
786 match scope {
787 PolicyBranchScope::Any => "true".to_string(),
788 PolicyBranchScope::Protected => {
789 "context.has_target_branch && context.target_branch_is_protected".to_string()
790 }
791 PolicyBranchScope::Unprotected => {
792 "context.has_target_branch && context.target_branch_is_protected == false".to_string()
793 }
794 }
795}
796
797fn policy_schema_source() -> &'static str {
798 r#"
804namespace Omnigraph {
805 type RequestContext = {
806 has_branch: Bool,
807 branch: String,
808 has_target_branch: Bool,
809 target_branch: String,
810 branch_is_protected: Bool,
811 target_branch_is_protected: Bool,
812 };
813
814 entity Actor in [Group];
815 entity Group;
816 entity Graph;
817 entity Server;
818
819 action "read" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
820 action "export" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
821 action "change" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
822 action "schema_apply" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
823 action "branch_create" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
824 action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
825 action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
826 action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
827 action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
828
829 action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext };
830}
831"#
832}
833
834const SERVER_RESOURCE_ID: &str = "root";
838
839fn entity_uid(entity_type: &str, id: &str) -> Result<EntityUid> {
840 let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?;
841 let entity_id = EntityId::from_str(id).map_err(|err| eyre!(err.to_string()))?;
842 Ok(EntityUid::from_type_name_and_id(typename, entity_id))
843}
844
845fn cedar_literal(value: &str) -> String {
846 serde_json::to_string(value).expect("string literal should serialize")
847}
848
849impl PolicyRequest {
850 pub fn action(&self) -> PolicyAction {
851 self.action
852 }
853
854 pub fn branch(&self) -> Option<&str> {
855 self.branch.as_deref()
856 }
857
858 pub fn target_branch(&self) -> Option<&str> {
859 self.target_branch.as_deref()
860 }
861}
862
863#[derive(Debug, Clone, Eq, PartialEq)]
896pub enum ResourceScope {
897 Graph,
901 Branch(String),
905 TargetBranch(String),
911 BranchTransition { source: String, target: String },
917}
918
919impl ResourceScope {
920 pub fn to_branch_pair(&self) -> (Option<&str>, Option<&str>) {
925 match self {
926 ResourceScope::Graph => (None, None),
927 ResourceScope::Branch(branch) => (Some(branch.as_str()), None),
928 ResourceScope::TargetBranch(target) => (None, Some(target.as_str())),
929 ResourceScope::BranchTransition { source, target } => {
930 (Some(source.as_str()), Some(target.as_str()))
931 }
932 }
933 }
934}
935
936#[derive(Debug, Clone)]
940pub enum PolicyError {
941 Denied(String),
943 Internal(String),
946}
947
948impl fmt::Display for PolicyError {
949 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
950 match self {
951 PolicyError::Denied(msg) => write!(f, "policy denied: {msg}"),
952 PolicyError::Internal(msg) => write!(f, "policy evaluation failed: {msg}"),
953 }
954 }
955}
956
957impl std::error::Error for PolicyError {}
958
959pub trait PolicyChecker: Send + Sync {
968 fn check(
972 &self,
973 action: PolicyAction,
974 scope: &ResourceScope,
975 actor: &str,
976 ) -> Result<(), PolicyError>;
977}
978
979impl PolicyChecker for PolicyEngine {
980 fn check(
981 &self,
982 action: PolicyAction,
983 scope: &ResourceScope,
984 actor: &str,
985 ) -> Result<(), PolicyError> {
986 let (branch, target_branch) = scope.to_branch_pair();
987 let request = PolicyRequest {
988 action,
989 branch: branch.map(|s| s.to_string()),
990 target_branch: target_branch.map(|s| s.to_string()),
991 };
992 let decision = self
993 .authorize(actor, &request)
994 .map_err(|e| PolicyError::Internal(e.to_string()))?;
995 if decision.allowed {
996 Ok(())
997 } else {
998 Err(PolicyError::Denied(decision.message))
999 }
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::{
1006 PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest,
1007 PolicyTestCase, PolicyTestConfig,
1008 };
1009
1010 #[test]
1011 fn rejects_duplicate_rule_ids() {
1012 let policy: PolicyConfig = serde_yaml::from_str(
1013 r#"
1014version: 1
1015groups:
1016 team: [act-andrew]
1017rules:
1018 - id: same
1019 allow:
1020 actors: { group: team }
1021 actions: [read]
1022 branch_scope: any
1023 - id: same
1024 allow:
1025 actors: { group: team }
1026 actions: [export]
1027 branch_scope: any
1028"#,
1029 )
1030 .unwrap();
1031
1032 let err = policy.validate().unwrap_err();
1033 assert!(err.to_string().contains("duplicate policy rule id"));
1034 }
1035
1036 #[test]
1037 fn rejects_unknown_group_references() {
1038 let policy: PolicyConfig = serde_yaml::from_str(
1039 r#"
1040version: 1
1041groups:
1042 team: [act-andrew]
1043rules:
1044 - id: bad
1045 allow:
1046 actors: { group: admins }
1047 actions: [read]
1048 branch_scope: any
1049"#,
1050 )
1051 .unwrap();
1052
1053 let err = policy.validate().unwrap_err();
1054 assert!(err.to_string().contains("references unknown group"));
1055 }
1056
1057 #[test]
1058 fn rejects_invalid_scope_action_combinations() {
1059 let policy: PolicyConfig = serde_yaml::from_str(
1060 r#"
1061version: 1
1062groups:
1063 team: [act-andrew]
1064rules:
1065 - id: bad
1066 allow:
1067 actors: { group: team }
1068 actions: [branch_merge]
1069 branch_scope: protected
1070"#,
1071 )
1072 .unwrap();
1073
1074 let err = policy.validate().unwrap_err();
1075 assert!(err.to_string().contains("unsupported action"));
1076 }
1077
1078 #[test]
1079 fn compiles_and_authorizes_branch_and_target_rules() {
1080 let policy: PolicyConfig = serde_yaml::from_str(
1081 r#"
1082version: 1
1083groups:
1084 team: [act-andrew, act-bruno]
1085 admins: [act-andrew]
1086protected_branches: [main]
1087rules:
1088 - id: team-read
1089 allow:
1090 actors: { group: team }
1091 actions: [read, export]
1092 branch_scope: any
1093 - id: team-write
1094 allow:
1095 actors: { group: team }
1096 actions: [change]
1097 branch_scope: unprotected
1098 - id: admins-promote
1099 allow:
1100 actors: { group: admins }
1101 actions: [branch_delete, branch_merge]
1102 target_branch_scope: protected
1103"#,
1104 )
1105 .unwrap();
1106
1107 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1108 let allow = engine
1109 .authorize(
1110 "act-bruno",
1111 &PolicyRequest {
1112 action: PolicyAction::Change,
1113 branch: Some("feature".to_string()),
1114 target_branch: None,
1115 },
1116 )
1117 .unwrap();
1118 assert!(allow.allowed);
1119 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-write"));
1120
1121 let deny = engine
1122 .authorize(
1123 "act-bruno",
1124 &PolicyRequest {
1125 action: PolicyAction::BranchDelete,
1126 branch: None,
1127 target_branch: Some("main".to_string()),
1128 },
1129 )
1130 .unwrap();
1131 assert!(!deny.allowed);
1132
1133 let admin = engine
1134 .authorize(
1135 "act-andrew",
1136 &PolicyRequest {
1137 action: PolicyAction::BranchDelete,
1138 branch: None,
1139 target_branch: Some("main".to_string()),
1140 },
1141 )
1142 .unwrap();
1143 assert!(admin.allowed);
1144 assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
1145 }
1146
1147 #[test]
1148 fn policy_tests_enforce_expected_outcomes() {
1149 let policy: PolicyConfig = serde_yaml::from_str(
1150 r#"
1151version: 1
1152groups:
1153 team: [act-andrew]
1154protected_branches: [main]
1155rules:
1156 - id: team-read
1157 allow:
1158 actors: { group: team }
1159 actions: [read]
1160 branch_scope: any
1161"#,
1162 )
1163 .unwrap();
1164 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1165 let tests = PolicyTestConfig {
1166 version: 1,
1167 cases: vec![
1168 PolicyTestCase {
1169 id: "allow-read".to_string(),
1170 actor: "act-andrew".to_string(),
1171 action: PolicyAction::Read,
1172 branch: Some("main".to_string()),
1173 target_branch: None,
1174 expect: PolicyExpectation::Allow,
1175 },
1176 PolicyTestCase {
1177 id: "deny-change".to_string(),
1178 actor: "act-andrew".to_string(),
1179 action: PolicyAction::Change,
1180 branch: Some("main".to_string()),
1181 target_branch: None,
1182 expect: PolicyExpectation::Deny,
1183 },
1184 ],
1185 };
1186
1187 engine.run_tests(&tests).unwrap();
1188 }
1189
1190 #[test]
1191 fn schema_apply_uses_target_branch_scope() {
1192 let policy: PolicyConfig = serde_yaml::from_str(
1193 r#"
1194version: 1
1195groups:
1196 admins: [act-ragnor]
1197protected_branches: [main]
1198rules:
1199 - id: admins-schema-apply
1200 allow:
1201 actors: { group: admins }
1202 actions: [schema_apply]
1203 target_branch_scope: protected
1204"#,
1205 )
1206 .unwrap();
1207
1208 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1209 let allow = engine
1210 .authorize(
1211 "act-ragnor",
1212 &PolicyRequest {
1213 action: PolicyAction::SchemaApply,
1214 branch: None,
1215 target_branch: Some("main".to_string()),
1216 },
1217 )
1218 .unwrap();
1219 assert!(allow.allowed);
1220
1221 let deny = engine
1222 .authorize(
1223 "act-ragnor",
1224 &PolicyRequest {
1225 action: PolicyAction::SchemaApply,
1226 branch: None,
1227 target_branch: Some("feature".to_string()),
1228 },
1229 )
1230 .unwrap();
1231 assert!(!deny.allowed);
1232 }
1233
1234 #[test]
1237 fn graph_list_action_authorizes_against_server_resource() {
1238 let policy: PolicyConfig = serde_yaml::from_str(
1239 r#"
1240version: 1
1241groups:
1242 admins: [act-andrew]
1243 viewers: [act-bruno]
1244rules:
1245 - id: admins-list-graphs
1246 allow:
1247 actors: { group: admins }
1248 actions: [graph_list]
1249"#,
1250 )
1251 .unwrap();
1252
1253 let engine = PolicyCompiler::compile(&policy, "ignored").unwrap();
1258
1259 let allow = engine
1260 .authorize(
1261 "act-andrew",
1262 &PolicyRequest {
1263 action: PolicyAction::GraphList,
1264 branch: None,
1265 target_branch: None,
1266 },
1267 )
1268 .unwrap();
1269 assert!(allow.allowed);
1270 assert_eq!(allow.matched_rule_id.as_deref(), Some("admins-list-graphs"));
1271
1272 let deny = engine
1274 .authorize(
1275 "act-bruno",
1276 &PolicyRequest {
1277 action: PolicyAction::GraphList,
1278 branch: None,
1279 target_branch: None,
1280 },
1281 )
1282 .unwrap();
1283 assert!(!deny.allowed);
1284 }
1285
1286 #[test]
1287 fn invoke_query_authorizes_per_graph() {
1288 let policy: PolicyConfig = serde_yaml::from_str(
1289 r#"
1290version: 1
1291groups:
1292 team: [act-alice]
1293 others: [act-bruno]
1294rules:
1295 - id: team-invoke-queries
1296 allow:
1297 actors: { group: team }
1298 actions: [invoke_query]
1299"#,
1300 )
1301 .unwrap();
1302 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1303
1304 let allow = engine
1305 .authorize(
1306 "act-alice",
1307 &PolicyRequest {
1308 action: PolicyAction::InvokeQuery,
1309 branch: None,
1310 target_branch: None,
1311 },
1312 )
1313 .unwrap();
1314 assert!(allow.allowed);
1315 assert_eq!(
1316 allow.matched_rule_id.as_deref(),
1317 Some("team-invoke-queries")
1318 );
1319
1320 let deny = engine
1322 .authorize(
1323 "act-bruno",
1324 &PolicyRequest {
1325 action: PolicyAction::InvokeQuery,
1326 branch: None,
1327 target_branch: None,
1328 },
1329 )
1330 .unwrap();
1331 assert!(!deny.allowed);
1332 }
1333
1334 #[test]
1335 fn invoke_query_rejects_branch_scope() {
1336 let policy: PolicyConfig = serde_yaml::from_str(
1340 r#"
1341version: 1
1342groups:
1343 team: [act-alice]
1344rules:
1345 - id: team-invoke-any-branch
1346 allow:
1347 actors: { group: team }
1348 actions: [invoke_query]
1349 branch_scope: any
1350"#,
1351 )
1352 .unwrap();
1353 let err = policy.validate().unwrap_err().to_string();
1354 assert!(
1355 err.contains("branch_scope") && err.contains("invoke_query"),
1356 "branch_scope on invoke_query must be rejected: {err}"
1357 );
1358 }
1359
1360 #[test]
1361 fn server_scoped_rule_cannot_use_branch_scope() {
1362 let policy: PolicyConfig = serde_yaml::from_str(
1363 r#"
1364version: 1
1365groups:
1366 admins: [act-andrew]
1367rules:
1368 - id: bad-branch-scope-on-graph-list
1369 allow:
1370 actors: { group: admins }
1371 actions: [graph_list]
1372 branch_scope: any
1373"#,
1374 )
1375 .unwrap();
1376 let err = policy.validate().unwrap_err();
1377 let msg = err.to_string();
1378 assert!(
1379 msg.contains("branch_scope") || msg.contains("server-scoped"),
1380 "expected branch_scope rejection for server-scoped action; got: {msg}"
1381 );
1382 }
1383
1384 #[test]
1385 fn rule_mixing_server_and_per_graph_actions_is_rejected() {
1386 let policy: PolicyConfig = serde_yaml::from_str(
1390 r#"
1391version: 1
1392groups:
1393 admins: [act-andrew]
1394rules:
1395 - id: mixed-resource-kinds
1396 allow:
1397 actors: { group: admins }
1398 actions: [graph_list, read]
1399"#,
1400 )
1401 .unwrap();
1402 let err = policy.validate().unwrap_err();
1403 let msg = err.to_string();
1404 assert!(
1405 msg.contains("server-scoped") || msg.contains("split into separate rules"),
1406 "expected mix-resource-kinds rejection; got: {msg}"
1407 );
1408 }
1409
1410 #[test]
1411 fn per_graph_rules_continue_to_work_alongside_server_rules() {
1412 let policy: PolicyConfig = serde_yaml::from_str(
1418 r#"
1419version: 1
1420groups:
1421 team: [act-andrew]
1422protected_branches: [main]
1423rules:
1424 - id: team-read
1425 allow:
1426 actors: { group: team }
1427 actions: [read, export]
1428 branch_scope: any
1429"#,
1430 )
1431 .unwrap();
1432 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1433 let allow = engine
1434 .authorize(
1435 "act-andrew",
1436 &PolicyRequest {
1437 action: PolicyAction::Read,
1438 branch: Some("main".to_string()),
1439 target_branch: None,
1440 },
1441 )
1442 .unwrap();
1443 assert!(allow.allowed);
1444 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-read"));
1445 }
1446
1447 #[test]
1455 fn load_graph_rejects_server_scoped_action() {
1456 let dir = tempfile::tempdir().unwrap();
1457 let path = dir.path().join("bad-graph-policy.yaml");
1458 std::fs::write(
1459 &path,
1460 r#"
1461version: 1
1462groups:
1463 admins: [act-andrew]
1464rules:
1465 - id: misplaced-graph-list
1466 allow:
1467 actors: { group: admins }
1468 actions: [graph_list]
1469"#,
1470 )
1471 .unwrap();
1472 let err = match PolicyEngine::load_graph(&path, "g1") {
1473 Ok(_) => panic!("expected server-scoped action in per-graph file to be rejected"),
1474 Err(e) => e,
1475 };
1476 let msg = err.to_string();
1477 assert!(
1478 msg.contains("server-scoped") && msg.contains("graph_list"),
1479 "expected server-scoped-in-graph-file rejection, got: {msg}"
1480 );
1481 }
1482
1483 #[test]
1487 fn load_server_rejects_per_graph_action() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let path = dir.path().join("bad-server-policy.yaml");
1490 std::fs::write(
1491 &path,
1492 r#"
1493version: 1
1494groups:
1495 team: [act-andrew]
1496rules:
1497 - id: misplaced-read
1498 allow:
1499 actors: { group: team }
1500 actions: [read]
1501 branch_scope: any
1502"#,
1503 )
1504 .unwrap();
1505 let err = match PolicyEngine::load_server(&path) {
1506 Ok(_) => panic!("expected per-graph action in server file to be rejected"),
1507 Err(e) => e,
1508 };
1509 let msg = err.to_string();
1510 assert!(
1511 msg.contains("per-graph") && msg.contains("read"),
1512 "expected per-graph-in-server-file rejection, got: {msg}"
1513 );
1514 }
1515
1516 #[test]
1520 fn load_graph_accepts_per_graph_only_policy() {
1521 let dir = tempfile::tempdir().unwrap();
1522 let path = dir.path().join("ok-graph-policy.yaml");
1523 std::fs::write(
1524 &path,
1525 r#"
1526version: 1
1527groups:
1528 team: [act-andrew]
1529rules:
1530 - id: team-read
1531 allow:
1532 actors: { group: team }
1533 actions: [read]
1534 branch_scope: any
1535"#,
1536 )
1537 .unwrap();
1538 let engine = PolicyEngine::load_graph(&path, "g1").unwrap();
1539 let decision = engine
1540 .authorize(
1541 "act-andrew",
1542 &PolicyRequest {
1543 action: PolicyAction::Read,
1544 branch: Some("main".to_string()),
1545 target_branch: None,
1546 },
1547 )
1548 .unwrap();
1549 assert!(decision.allowed);
1550 }
1551
1552 #[test]
1555 fn load_server_accepts_server_only_policy() {
1556 let dir = tempfile::tempdir().unwrap();
1557 let path = dir.path().join("ok-server-policy.yaml");
1558 std::fs::write(
1559 &path,
1560 r#"
1561version: 1
1562groups:
1563 admins: [act-andrew]
1564rules:
1565 - id: admins-list-graphs
1566 allow:
1567 actors: { group: admins }
1568 actions: [graph_list]
1569"#,
1570 )
1571 .unwrap();
1572 let engine = PolicyEngine::load_server(&path).unwrap();
1573 let decision = engine
1574 .authorize(
1575 "act-andrew",
1576 &PolicyRequest {
1577 action: PolicyAction::GraphList,
1578 branch: None,
1579 target_branch: None,
1580 },
1581 )
1582 .unwrap();
1583 assert!(decision.allowed);
1584 }
1585}