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 Self::from_source(&fs::read_to_string(path)?)
281 }
282
283 pub fn from_source(source: &str) -> Result<Self> {
287 let config: Self = serde_yaml::from_str(source)?;
288 config.validate()?;
289 Ok(config)
290 }
291
292 pub fn validate(&self) -> Result<()> {
293 if self.version != 1 {
294 bail!("policy version must be 1");
295 }
296
297 for (group, members) in &self.groups {
298 if group.trim().is_empty() {
299 bail!("policy group names must not be blank");
300 }
301 if members.is_empty() {
302 bail!("policy group '{group}' must not be empty");
303 }
304 for actor in members {
305 if actor.trim().is_empty() {
306 bail!("policy group '{group}' contains a blank actor id");
307 }
308 }
309 }
310
311 for branch in &self.protected_branches {
312 if branch.trim().is_empty() {
313 bail!("protected branch names must not be blank");
314 }
315 }
316
317 let mut seen_rule_ids = HashSet::new();
318 for rule in &self.rules {
319 if rule.id.trim().is_empty() {
320 bail!("policy rule ids must not be blank");
321 }
322 if !seen_rule_ids.insert(rule.id.clone()) {
323 bail!("duplicate policy rule id '{}'", rule.id);
324 }
325 if rule.allow.actors.group.trim().is_empty() {
326 bail!("policy rule '{}' must reference a non-blank group", rule.id);
327 }
328 if !self.groups.contains_key(rule.allow.actors.group.as_str()) {
329 bail!(
330 "policy rule '{}' references unknown group '{}'",
331 rule.id,
332 rule.allow.actors.group
333 );
334 }
335 if rule.allow.actions.is_empty() {
336 bail!("policy rule '{}' must include at least one action", rule.id);
337 }
338 if rule.allow.branch_scope.is_some() && rule.allow.target_branch_scope.is_some() {
339 bail!(
340 "policy rule '{}' may specify branch_scope or target_branch_scope, not both",
341 rule.id
342 );
343 }
344 if let Some(_) = rule.allow.branch_scope {
345 for action in &rule.allow.actions {
346 if !action.uses_branch_scope() {
347 bail!(
348 "policy rule '{}' uses branch_scope with unsupported action '{}'",
349 rule.id,
350 action
351 );
352 }
353 }
354 }
355 if let Some(_) = rule.allow.target_branch_scope {
356 for action in &rule.allow.actions {
357 if !action.uses_target_branch_scope() {
358 bail!(
359 "policy rule '{}' uses target_branch_scope with unsupported action '{}'",
360 rule.id,
361 action
362 );
363 }
364 }
365 }
366 let mut server_scoped = false;
371 let mut graph_scoped = false;
372 for action in &rule.allow.actions {
373 match action.resource_kind() {
374 PolicyResourceKind::Server => server_scoped = true,
375 PolicyResourceKind::Graph => graph_scoped = true,
376 }
377 }
378 if server_scoped && graph_scoped {
379 bail!(
380 "policy rule '{}' mixes the server-scoped action `graph_list` \
381 with per-graph actions; split into separate rules",
382 rule.id
383 );
384 }
385 if server_scoped
386 && (rule.allow.branch_scope.is_some() || rule.allow.target_branch_scope.is_some())
387 {
388 bail!(
389 "policy rule '{}' uses branch_scope/target_branch_scope with a \
390 server-scoped action; server-scoped actions have no branch context",
391 rule.id
392 );
393 }
394 }
395
396 Ok(())
397 }
398}
399
400impl PolicyTestConfig {
401 pub fn load(path: &Path) -> Result<Self> {
402 let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
403 if config.version != 1 {
404 bail!("policy test version must be 1");
405 }
406 let mut seen = HashSet::new();
407 for case in &config.cases {
408 if case.id.trim().is_empty() {
409 bail!("policy test case ids must not be blank");
410 }
411 if !seen.insert(case.id.clone()) {
412 bail!("duplicate policy test case id '{}'", case.id);
413 }
414 if case.actor.trim().is_empty() {
415 bail!("policy test case '{}' must not use a blank actor", case.id);
416 }
417 }
418 Ok(config)
419 }
420}
421
422impl PolicyCompiler {
423 pub fn compile(config: &PolicyConfig, graph_id: &str) -> Result<PolicyEngine> {
424 config.validate()?;
425 let (schema, schema_warnings) = Schema::from_cedarschema_str(policy_schema_source())?;
426 let schema_warnings = schema_warnings
427 .map(|warning| warning.to_string())
428 .collect::<Vec<_>>();
429 if !schema_warnings.is_empty() {
430 bail!("policy schema warnings:\n{}", schema_warnings.join("\n"));
431 }
432 let entities = compile_entities(config, graph_id, &schema)?;
433 let (policies, policy_to_rule) = compile_policies(config, graph_id)?;
434 let validator = Validator::new(schema.clone());
435 let validation = validator.validate(&policies, ValidationMode::Strict);
436 let errors = validation
437 .validation_errors()
438 .map(|err| err.to_string())
439 .collect::<Vec<_>>();
440 if !errors.is_empty() {
441 bail!("policy validation failed:\n{}", errors.join("\n"));
442 }
443
444 let known_actors = config
445 .groups
446 .values()
447 .flat_map(|members| members.iter().cloned())
448 .collect();
449 Ok(PolicyEngine {
450 graph_id: graph_id.to_string(),
451 protected_branches: config.protected_branches.iter().cloned().collect(),
452 known_actors,
453 schema,
454 entities,
455 policies,
456 policy_to_rule,
457 })
458 }
459}
460
461impl PolicyEngine {
462 pub fn load_graph(path: &Path, graph_id: &str) -> Result<Self> {
470 let config = PolicyConfig::load(path)?;
471 validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
472 PolicyCompiler::compile(&config, graph_id)
473 }
474
475 pub fn load_graph_from_source(source: &str, graph_id: &str) -> Result<Self> {
478 let config = PolicyConfig::from_source(source)?;
479 validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
480 PolicyCompiler::compile(&config, graph_id)
481 }
482
483 pub fn load_server(path: &Path) -> Result<Self> {
489 Self::load_server_from_source(&fs::read_to_string(path)?)
490 }
491
492 pub fn load_server_from_source(source: &str) -> Result<Self> {
494 let config = PolicyConfig::from_source(source)?;
495 validate_kind_alignment(&config, PolicyEngineKind::Server)?;
496 PolicyCompiler::compile(&config, SERVER_RESOURCE_ID)
502 }
503
504 pub fn authorize(&self, actor_id: &str, request: &PolicyRequest) -> Result<PolicyDecision> {
510 if !self.known_actors.contains(actor_id) {
511 return Ok(self.deny(
512 None,
513 format!(
514 "policy denied action '{}' for unknown actor '{}'",
515 request.action, actor_id
516 ),
517 ));
518 }
519
520 let principal = entity_uid("Actor", actor_id)?;
521 let action = entity_uid("Action", request.action.as_str())?;
522 let resource = match request.action.resource_kind() {
527 PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
528 PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
529 };
530 let context_value = json!({
531 "has_branch": request.branch.is_some(),
532 "branch": request.branch.clone().unwrap_or_default(),
533 "has_target_branch": request.target_branch.is_some(),
534 "target_branch": request.target_branch.clone().unwrap_or_default(),
535 "branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
536 "target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
537 });
538 let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
539 let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
540 let response =
541 Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
542 let errors = response
543 .diagnostics()
544 .errors()
545 .map(|err| err.to_string())
546 .collect::<Vec<_>>();
547 if !errors.is_empty() {
548 bail!("policy evaluation failed:\n{}", errors.join("\n"));
549 }
550
551 let matched_rule_id = response
552 .diagnostics()
553 .reason()
554 .filter_map(|policy_id| {
555 let key: &str = policy_id.as_ref();
556 self.policy_to_rule.get(key).cloned()
557 })
558 .min();
559
560 Ok(match response.decision() {
561 Decision::Allow => PolicyDecision {
562 allowed: true,
563 matched_rule_id: matched_rule_id.clone(),
564 message: format!(
565 "policy allowed action '{}' for actor '{}'",
566 request.action, actor_id
567 ),
568 },
569 Decision::Deny => {
570 let message = format!(
571 "policy denied action '{}'{}{} for actor '{}'",
572 request.action,
573 request
574 .branch
575 .as_deref()
576 .map(|branch| format!(" on branch '{}'", branch))
577 .unwrap_or_default(),
578 request
579 .target_branch
580 .as_deref()
581 .map(|branch| format!(" targeting branch '{}'", branch))
582 .unwrap_or_default(),
583 actor_id
584 );
585 self.deny(matched_rule_id, message)
586 }
587 })
588 }
589
590 pub fn run_tests(&self, tests: &PolicyTestConfig) -> Result<()> {
591 if tests.version != 1 {
592 bail!("policy test version must be 1");
593 }
594 let mut failures = Vec::new();
595 for case in &tests.cases {
596 let decision = self.authorize(
597 &case.actor,
598 &PolicyRequest {
599 action: case.action,
600 branch: case.branch.clone(),
601 target_branch: case.target_branch.clone(),
602 },
603 )?;
604 let expected_allowed = matches!(case.expect, PolicyExpectation::Allow);
605 if decision.allowed != expected_allowed {
606 failures.push(format!(
607 "{}: expected {:?} but got {}",
608 case.id,
609 case.expect,
610 if decision.allowed { "allow" } else { "deny" }
611 ));
612 }
613 }
614 if failures.is_empty() {
615 Ok(())
616 } else {
617 bail!("policy tests failed:\n{}", failures.join("\n"))
618 }
619 }
620
621 pub fn known_actor_count(&self) -> usize {
622 self.known_actors.len()
623 }
624
625 fn deny(&self, matched_rule_id: Option<String>, message: String) -> PolicyDecision {
626 PolicyDecision {
627 allowed: false,
628 matched_rule_id,
629 message,
630 }
631 }
632}
633
634fn validate_kind_alignment(config: &PolicyConfig, kind: PolicyEngineKind) -> Result<()> {
640 let required = match kind {
641 PolicyEngineKind::Graph => PolicyResourceKind::Graph,
642 PolicyEngineKind::Server => PolicyResourceKind::Server,
643 };
644 for rule in &config.rules {
645 for action in &rule.allow.actions {
646 if action.resource_kind() != required {
647 let (got, expected_file) = match action.resource_kind() {
648 PolicyResourceKind::Server => ("server-scoped", "server policy file"),
649 PolicyResourceKind::Graph => ("per-graph", "per-graph policy file"),
650 };
651 bail!(
652 "policy rule '{}' uses {} action '{}' in a {:?} policy file; \
653 move it to a {}",
654 rule.id,
655 got,
656 action,
657 kind,
658 expected_file
659 );
660 }
661 }
662 }
663 Ok(())
664}
665
666fn compile_entities(config: &PolicyConfig, graph_id: &str, schema: &Schema) -> Result<Entities> {
667 let mut group_entities = Vec::new();
668 for group in config.groups.keys() {
669 group_entities.push(Entity::new(
670 entity_uid("Group", group)?,
671 HashMap::new(),
672 HashSet::<EntityUid>::new(),
673 )?);
674 }
675
676 let mut actor_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
677 for (group, members) in &config.groups {
678 for actor in members {
679 actor_groups
680 .entry(actor.clone())
681 .or_default()
682 .insert(group.clone());
683 }
684 }
685
686 let mut actor_entities = Vec::new();
687 for (actor, groups) in actor_groups {
688 let parents = groups
689 .iter()
690 .map(|group| entity_uid("Group", group))
691 .collect::<Result<HashSet<_>>>()?;
692 actor_entities.push(Entity::new(
693 entity_uid("Actor", &actor)?,
694 HashMap::new(),
695 parents,
696 )?);
697 }
698
699 let graph_entity = Entity::new(
700 entity_uid("Graph", graph_id)?,
701 HashMap::new(),
702 HashSet::<EntityUid>::new(),
703 )?;
704
705 let mut entities = Vec::new();
706 entities.extend(group_entities);
707 entities.extend(actor_entities);
708 entities.push(graph_entity);
709
710 let any_server_scoped = config.rules.iter().any(|rule| {
716 rule.allow
717 .actions
718 .iter()
719 .any(|action| action.resource_kind() == PolicyResourceKind::Server)
720 });
721 if any_server_scoped {
722 entities.push(Entity::new(
723 entity_uid("Server", SERVER_RESOURCE_ID)?,
724 HashMap::new(),
725 HashSet::<EntityUid>::new(),
726 )?);
727 }
728
729 Ok(Entities::from_entities(entities, Some(schema))?)
730}
731
732fn compile_policies(
733 config: &PolicyConfig,
734 graph_id: &str,
735) -> Result<(PolicySet, HashMap<String, String>)> {
736 let mut policies = Vec::new();
737 let mut policy_to_rule = HashMap::new();
738
739 for rule in &config.rules {
740 for action in &rule.allow.actions {
741 let policy_id = PolicyId::new(format!("{}:{}", rule.id, action.as_str()));
742 let source = compile_policy_source(rule, action, graph_id);
743 let policy = Policy::parse(Some(policy_id.clone()), source.as_str())?;
744 policy_to_rule.insert(policy_id.to_string(), rule.id.clone());
745 policies.push(policy);
746 }
747 }
748
749 Ok((PolicySet::from_policies(policies)?, policy_to_rule))
750}
751
752fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &str) -> String {
753 let mut conditions = Vec::new();
754 if let Some(scope) = rule.allow.branch_scope {
755 conditions.push(branch_scope_condition(scope));
756 }
757 if let Some(scope) = rule.allow.target_branch_scope {
758 conditions.push(target_branch_scope_condition(scope));
759 }
760
761 let when = if conditions.is_empty() {
762 String::new()
763 } else {
764 format!("\nwhen {{ {} }}", conditions.join(" && "))
765 };
766
767 let resource_literal = match action.resource_kind() {
772 PolicyResourceKind::Graph => {
773 format!("Omnigraph::Graph::{}", cedar_literal(graph_id))
774 }
775 PolicyResourceKind::Server => {
776 format!("Omnigraph::Server::{}", cedar_literal(SERVER_RESOURCE_ID))
777 }
778 };
779
780 format!(
781 r#"permit (
782 principal in Omnigraph::Group::{group},
783 action == Omnigraph::Action::{action},
784 resource == {resource_literal}
785){when};"#,
786 group = cedar_literal(&rule.allow.actors.group),
787 action = cedar_literal(action.as_str()),
788 when = when,
789 resource_literal = resource_literal,
790 )
791}
792
793fn branch_scope_condition(scope: PolicyBranchScope) -> String {
794 match scope {
795 PolicyBranchScope::Any => "true".to_string(),
796 PolicyBranchScope::Protected => {
797 "context.has_branch && context.branch_is_protected".to_string()
798 }
799 PolicyBranchScope::Unprotected => {
800 "context.has_branch && context.branch_is_protected == false".to_string()
801 }
802 }
803}
804
805fn target_branch_scope_condition(scope: PolicyBranchScope) -> String {
806 match scope {
807 PolicyBranchScope::Any => "true".to_string(),
808 PolicyBranchScope::Protected => {
809 "context.has_target_branch && context.target_branch_is_protected".to_string()
810 }
811 PolicyBranchScope::Unprotected => {
812 "context.has_target_branch && context.target_branch_is_protected == false".to_string()
813 }
814 }
815}
816
817fn policy_schema_source() -> &'static str {
818 r#"
824namespace Omnigraph {
825 type RequestContext = {
826 has_branch: Bool,
827 branch: String,
828 has_target_branch: Bool,
829 target_branch: String,
830 branch_is_protected: Bool,
831 target_branch_is_protected: Bool,
832 };
833
834 entity Actor in [Group];
835 entity Group;
836 entity Graph;
837 entity Server;
838
839 action "read" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
840 action "export" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
841 action "change" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
842 action "schema_apply" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
843 action "branch_create" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
844 action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
845 action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
846 action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
847 action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
848
849 action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext };
850}
851"#
852}
853
854const SERVER_RESOURCE_ID: &str = "root";
858
859fn entity_uid(entity_type: &str, id: &str) -> Result<EntityUid> {
860 let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?;
861 let entity_id = EntityId::from_str(id).map_err(|err| eyre!(err.to_string()))?;
862 Ok(EntityUid::from_type_name_and_id(typename, entity_id))
863}
864
865fn cedar_literal(value: &str) -> String {
866 serde_json::to_string(value).expect("string literal should serialize")
867}
868
869impl PolicyRequest {
870 pub fn action(&self) -> PolicyAction {
871 self.action
872 }
873
874 pub fn branch(&self) -> Option<&str> {
875 self.branch.as_deref()
876 }
877
878 pub fn target_branch(&self) -> Option<&str> {
879 self.target_branch.as_deref()
880 }
881}
882
883#[derive(Debug, Clone, Eq, PartialEq)]
916pub enum ResourceScope {
917 Graph,
921 Branch(String),
925 TargetBranch(String),
931 BranchTransition { source: String, target: String },
937}
938
939impl ResourceScope {
940 pub fn to_branch_pair(&self) -> (Option<&str>, Option<&str>) {
945 match self {
946 ResourceScope::Graph => (None, None),
947 ResourceScope::Branch(branch) => (Some(branch.as_str()), None),
948 ResourceScope::TargetBranch(target) => (None, Some(target.as_str())),
949 ResourceScope::BranchTransition { source, target } => {
950 (Some(source.as_str()), Some(target.as_str()))
951 }
952 }
953 }
954}
955
956#[derive(Debug, Clone)]
960pub enum PolicyError {
961 Denied(String),
963 Internal(String),
966}
967
968impl fmt::Display for PolicyError {
969 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
970 match self {
971 PolicyError::Denied(msg) => write!(f, "policy denied: {msg}"),
972 PolicyError::Internal(msg) => write!(f, "policy evaluation failed: {msg}"),
973 }
974 }
975}
976
977impl std::error::Error for PolicyError {}
978
979pub trait PolicyChecker: Send + Sync {
988 fn check(
992 &self,
993 action: PolicyAction,
994 scope: &ResourceScope,
995 actor: &str,
996 ) -> Result<(), PolicyError>;
997}
998
999impl PolicyChecker for PolicyEngine {
1000 fn check(
1001 &self,
1002 action: PolicyAction,
1003 scope: &ResourceScope,
1004 actor: &str,
1005 ) -> Result<(), PolicyError> {
1006 let (branch, target_branch) = scope.to_branch_pair();
1007 let request = PolicyRequest {
1008 action,
1009 branch: branch.map(|s| s.to_string()),
1010 target_branch: target_branch.map(|s| s.to_string()),
1011 };
1012 let decision = self
1013 .authorize(actor, &request)
1014 .map_err(|e| PolicyError::Internal(e.to_string()))?;
1015 if decision.allowed {
1016 Ok(())
1017 } else {
1018 Err(PolicyError::Denied(decision.message))
1019 }
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025
1026 #[test]
1027 fn from_source_twins_match_path_loaders() {
1028 let yaml = r#"
1029version: 1
1030groups:
1031 readers: ["act-r"]
1032protected_branches: [main]
1033rules:
1034 - id: r1
1035 allow:
1036 actors: { group: readers }
1037 actions: [read]
1038 branch_scope: any
1039"#;
1040 let config = PolicyConfig::from_source(yaml).unwrap();
1041 assert_eq!(config.version, 1);
1042 let engine = PolicyEngine::load_graph_from_source(yaml, "g1").unwrap();
1043 drop(engine);
1044
1045 let server_yaml = r#"
1046version: 1
1047kind: server
1048groups:
1049 admins: ["act-a"]
1050rules:
1051 - id: s1
1052 allow:
1053 actors: { group: admins }
1054 actions: [graph_list]
1055"#;
1056 PolicyEngine::load_server_from_source(server_yaml).unwrap();
1057 assert!(PolicyEngine::load_graph_from_source(server_yaml, "g1").is_err());
1059 assert!(PolicyEngine::load_server_from_source(yaml).is_err());
1060 }
1061 use super::{
1062 PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest,
1063 PolicyTestCase, PolicyTestConfig,
1064 };
1065
1066 #[test]
1067 fn rejects_duplicate_rule_ids() {
1068 let policy: PolicyConfig = serde_yaml::from_str(
1069 r#"
1070version: 1
1071groups:
1072 team: [act-andrew]
1073rules:
1074 - id: same
1075 allow:
1076 actors: { group: team }
1077 actions: [read]
1078 branch_scope: any
1079 - id: same
1080 allow:
1081 actors: { group: team }
1082 actions: [export]
1083 branch_scope: any
1084"#,
1085 )
1086 .unwrap();
1087
1088 let err = policy.validate().unwrap_err();
1089 assert!(err.to_string().contains("duplicate policy rule id"));
1090 }
1091
1092 #[test]
1093 fn rejects_unknown_group_references() {
1094 let policy: PolicyConfig = serde_yaml::from_str(
1095 r#"
1096version: 1
1097groups:
1098 team: [act-andrew]
1099rules:
1100 - id: bad
1101 allow:
1102 actors: { group: admins }
1103 actions: [read]
1104 branch_scope: any
1105"#,
1106 )
1107 .unwrap();
1108
1109 let err = policy.validate().unwrap_err();
1110 assert!(err.to_string().contains("references unknown group"));
1111 }
1112
1113 #[test]
1114 fn rejects_invalid_scope_action_combinations() {
1115 let policy: PolicyConfig = serde_yaml::from_str(
1116 r#"
1117version: 1
1118groups:
1119 team: [act-andrew]
1120rules:
1121 - id: bad
1122 allow:
1123 actors: { group: team }
1124 actions: [branch_merge]
1125 branch_scope: protected
1126"#,
1127 )
1128 .unwrap();
1129
1130 let err = policy.validate().unwrap_err();
1131 assert!(err.to_string().contains("unsupported action"));
1132 }
1133
1134 #[test]
1135 fn compiles_and_authorizes_branch_and_target_rules() {
1136 let policy: PolicyConfig = serde_yaml::from_str(
1137 r#"
1138version: 1
1139groups:
1140 team: [act-andrew, act-bruno]
1141 admins: [act-andrew]
1142protected_branches: [main]
1143rules:
1144 - id: team-read
1145 allow:
1146 actors: { group: team }
1147 actions: [read, export]
1148 branch_scope: any
1149 - id: team-write
1150 allow:
1151 actors: { group: team }
1152 actions: [change]
1153 branch_scope: unprotected
1154 - id: admins-promote
1155 allow:
1156 actors: { group: admins }
1157 actions: [branch_delete, branch_merge]
1158 target_branch_scope: protected
1159"#,
1160 )
1161 .unwrap();
1162
1163 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1164 let allow = engine
1165 .authorize(
1166 "act-bruno",
1167 &PolicyRequest {
1168 action: PolicyAction::Change,
1169 branch: Some("feature".to_string()),
1170 target_branch: None,
1171 },
1172 )
1173 .unwrap();
1174 assert!(allow.allowed);
1175 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-write"));
1176
1177 let deny = engine
1178 .authorize(
1179 "act-bruno",
1180 &PolicyRequest {
1181 action: PolicyAction::BranchDelete,
1182 branch: None,
1183 target_branch: Some("main".to_string()),
1184 },
1185 )
1186 .unwrap();
1187 assert!(!deny.allowed);
1188
1189 let admin = engine
1190 .authorize(
1191 "act-andrew",
1192 &PolicyRequest {
1193 action: PolicyAction::BranchDelete,
1194 branch: None,
1195 target_branch: Some("main".to_string()),
1196 },
1197 )
1198 .unwrap();
1199 assert!(admin.allowed);
1200 assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
1201 }
1202
1203 #[test]
1204 fn policy_tests_enforce_expected_outcomes() {
1205 let policy: PolicyConfig = serde_yaml::from_str(
1206 r#"
1207version: 1
1208groups:
1209 team: [act-andrew]
1210protected_branches: [main]
1211rules:
1212 - id: team-read
1213 allow:
1214 actors: { group: team }
1215 actions: [read]
1216 branch_scope: any
1217"#,
1218 )
1219 .unwrap();
1220 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1221 let tests = PolicyTestConfig {
1222 version: 1,
1223 cases: vec![
1224 PolicyTestCase {
1225 id: "allow-read".to_string(),
1226 actor: "act-andrew".to_string(),
1227 action: PolicyAction::Read,
1228 branch: Some("main".to_string()),
1229 target_branch: None,
1230 expect: PolicyExpectation::Allow,
1231 },
1232 PolicyTestCase {
1233 id: "deny-change".to_string(),
1234 actor: "act-andrew".to_string(),
1235 action: PolicyAction::Change,
1236 branch: Some("main".to_string()),
1237 target_branch: None,
1238 expect: PolicyExpectation::Deny,
1239 },
1240 ],
1241 };
1242
1243 engine.run_tests(&tests).unwrap();
1244 }
1245
1246 #[test]
1247 fn schema_apply_uses_target_branch_scope() {
1248 let policy: PolicyConfig = serde_yaml::from_str(
1249 r#"
1250version: 1
1251groups:
1252 admins: [act-ragnor]
1253protected_branches: [main]
1254rules:
1255 - id: admins-schema-apply
1256 allow:
1257 actors: { group: admins }
1258 actions: [schema_apply]
1259 target_branch_scope: protected
1260"#,
1261 )
1262 .unwrap();
1263
1264 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1265 let allow = engine
1266 .authorize(
1267 "act-ragnor",
1268 &PolicyRequest {
1269 action: PolicyAction::SchemaApply,
1270 branch: None,
1271 target_branch: Some("main".to_string()),
1272 },
1273 )
1274 .unwrap();
1275 assert!(allow.allowed);
1276
1277 let deny = engine
1278 .authorize(
1279 "act-ragnor",
1280 &PolicyRequest {
1281 action: PolicyAction::SchemaApply,
1282 branch: None,
1283 target_branch: Some("feature".to_string()),
1284 },
1285 )
1286 .unwrap();
1287 assert!(!deny.allowed);
1288 }
1289
1290 #[test]
1293 fn graph_list_action_authorizes_against_server_resource() {
1294 let policy: PolicyConfig = serde_yaml::from_str(
1295 r#"
1296version: 1
1297groups:
1298 admins: [act-andrew]
1299 viewers: [act-bruno]
1300rules:
1301 - id: admins-list-graphs
1302 allow:
1303 actors: { group: admins }
1304 actions: [graph_list]
1305"#,
1306 )
1307 .unwrap();
1308
1309 let engine = PolicyCompiler::compile(&policy, "ignored").unwrap();
1314
1315 let allow = engine
1316 .authorize(
1317 "act-andrew",
1318 &PolicyRequest {
1319 action: PolicyAction::GraphList,
1320 branch: None,
1321 target_branch: None,
1322 },
1323 )
1324 .unwrap();
1325 assert!(allow.allowed);
1326 assert_eq!(allow.matched_rule_id.as_deref(), Some("admins-list-graphs"));
1327
1328 let deny = engine
1330 .authorize(
1331 "act-bruno",
1332 &PolicyRequest {
1333 action: PolicyAction::GraphList,
1334 branch: None,
1335 target_branch: None,
1336 },
1337 )
1338 .unwrap();
1339 assert!(!deny.allowed);
1340 }
1341
1342 #[test]
1343 fn invoke_query_authorizes_per_graph() {
1344 let policy: PolicyConfig = serde_yaml::from_str(
1345 r#"
1346version: 1
1347groups:
1348 team: [act-alice]
1349 others: [act-bruno]
1350rules:
1351 - id: team-invoke-queries
1352 allow:
1353 actors: { group: team }
1354 actions: [invoke_query]
1355"#,
1356 )
1357 .unwrap();
1358 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1359
1360 let allow = engine
1361 .authorize(
1362 "act-alice",
1363 &PolicyRequest {
1364 action: PolicyAction::InvokeQuery,
1365 branch: None,
1366 target_branch: None,
1367 },
1368 )
1369 .unwrap();
1370 assert!(allow.allowed);
1371 assert_eq!(
1372 allow.matched_rule_id.as_deref(),
1373 Some("team-invoke-queries")
1374 );
1375
1376 let deny = engine
1378 .authorize(
1379 "act-bruno",
1380 &PolicyRequest {
1381 action: PolicyAction::InvokeQuery,
1382 branch: None,
1383 target_branch: None,
1384 },
1385 )
1386 .unwrap();
1387 assert!(!deny.allowed);
1388 }
1389
1390 #[test]
1391 fn invoke_query_rejects_branch_scope() {
1392 let policy: PolicyConfig = serde_yaml::from_str(
1396 r#"
1397version: 1
1398groups:
1399 team: [act-alice]
1400rules:
1401 - id: team-invoke-any-branch
1402 allow:
1403 actors: { group: team }
1404 actions: [invoke_query]
1405 branch_scope: any
1406"#,
1407 )
1408 .unwrap();
1409 let err = policy.validate().unwrap_err().to_string();
1410 assert!(
1411 err.contains("branch_scope") && err.contains("invoke_query"),
1412 "branch_scope on invoke_query must be rejected: {err}"
1413 );
1414 }
1415
1416 #[test]
1417 fn server_scoped_rule_cannot_use_branch_scope() {
1418 let policy: PolicyConfig = serde_yaml::from_str(
1419 r#"
1420version: 1
1421groups:
1422 admins: [act-andrew]
1423rules:
1424 - id: bad-branch-scope-on-graph-list
1425 allow:
1426 actors: { group: admins }
1427 actions: [graph_list]
1428 branch_scope: any
1429"#,
1430 )
1431 .unwrap();
1432 let err = policy.validate().unwrap_err();
1433 let msg = err.to_string();
1434 assert!(
1435 msg.contains("branch_scope") || msg.contains("server-scoped"),
1436 "expected branch_scope rejection for server-scoped action; got: {msg}"
1437 );
1438 }
1439
1440 #[test]
1441 fn rule_mixing_server_and_per_graph_actions_is_rejected() {
1442 let policy: PolicyConfig = serde_yaml::from_str(
1446 r#"
1447version: 1
1448groups:
1449 admins: [act-andrew]
1450rules:
1451 - id: mixed-resource-kinds
1452 allow:
1453 actors: { group: admins }
1454 actions: [graph_list, read]
1455"#,
1456 )
1457 .unwrap();
1458 let err = policy.validate().unwrap_err();
1459 let msg = err.to_string();
1460 assert!(
1461 msg.contains("server-scoped") || msg.contains("split into separate rules"),
1462 "expected mix-resource-kinds rejection; got: {msg}"
1463 );
1464 }
1465
1466 #[test]
1467 fn per_graph_rules_continue_to_work_alongside_server_rules() {
1468 let policy: PolicyConfig = serde_yaml::from_str(
1474 r#"
1475version: 1
1476groups:
1477 team: [act-andrew]
1478protected_branches: [main]
1479rules:
1480 - id: team-read
1481 allow:
1482 actors: { group: team }
1483 actions: [read, export]
1484 branch_scope: any
1485"#,
1486 )
1487 .unwrap();
1488 let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1489 let allow = engine
1490 .authorize(
1491 "act-andrew",
1492 &PolicyRequest {
1493 action: PolicyAction::Read,
1494 branch: Some("main".to_string()),
1495 target_branch: None,
1496 },
1497 )
1498 .unwrap();
1499 assert!(allow.allowed);
1500 assert_eq!(allow.matched_rule_id.as_deref(), Some("team-read"));
1501 }
1502
1503 #[test]
1511 fn load_graph_rejects_server_scoped_action() {
1512 let dir = tempfile::tempdir().unwrap();
1513 let path = dir.path().join("bad-graph-policy.yaml");
1514 std::fs::write(
1515 &path,
1516 r#"
1517version: 1
1518groups:
1519 admins: [act-andrew]
1520rules:
1521 - id: misplaced-graph-list
1522 allow:
1523 actors: { group: admins }
1524 actions: [graph_list]
1525"#,
1526 )
1527 .unwrap();
1528 let err = match PolicyEngine::load_graph(&path, "g1") {
1529 Ok(_) => panic!("expected server-scoped action in per-graph file to be rejected"),
1530 Err(e) => e,
1531 };
1532 let msg = err.to_string();
1533 assert!(
1534 msg.contains("server-scoped") && msg.contains("graph_list"),
1535 "expected server-scoped-in-graph-file rejection, got: {msg}"
1536 );
1537 }
1538
1539 #[test]
1543 fn load_server_rejects_per_graph_action() {
1544 let dir = tempfile::tempdir().unwrap();
1545 let path = dir.path().join("bad-server-policy.yaml");
1546 std::fs::write(
1547 &path,
1548 r#"
1549version: 1
1550groups:
1551 team: [act-andrew]
1552rules:
1553 - id: misplaced-read
1554 allow:
1555 actors: { group: team }
1556 actions: [read]
1557 branch_scope: any
1558"#,
1559 )
1560 .unwrap();
1561 let err = match PolicyEngine::load_server(&path) {
1562 Ok(_) => panic!("expected per-graph action in server file to be rejected"),
1563 Err(e) => e,
1564 };
1565 let msg = err.to_string();
1566 assert!(
1567 msg.contains("per-graph") && msg.contains("read"),
1568 "expected per-graph-in-server-file rejection, got: {msg}"
1569 );
1570 }
1571
1572 #[test]
1576 fn load_graph_accepts_per_graph_only_policy() {
1577 let dir = tempfile::tempdir().unwrap();
1578 let path = dir.path().join("ok-graph-policy.yaml");
1579 std::fs::write(
1580 &path,
1581 r#"
1582version: 1
1583groups:
1584 team: [act-andrew]
1585rules:
1586 - id: team-read
1587 allow:
1588 actors: { group: team }
1589 actions: [read]
1590 branch_scope: any
1591"#,
1592 )
1593 .unwrap();
1594 let engine = PolicyEngine::load_graph(&path, "g1").unwrap();
1595 let decision = engine
1596 .authorize(
1597 "act-andrew",
1598 &PolicyRequest {
1599 action: PolicyAction::Read,
1600 branch: Some("main".to_string()),
1601 target_branch: None,
1602 },
1603 )
1604 .unwrap();
1605 assert!(decision.allowed);
1606 }
1607
1608 #[test]
1611 fn load_server_accepts_server_only_policy() {
1612 let dir = tempfile::tempdir().unwrap();
1613 let path = dir.path().join("ok-server-policy.yaml");
1614 std::fs::write(
1615 &path,
1616 r#"
1617version: 1
1618groups:
1619 admins: [act-andrew]
1620rules:
1621 - id: admins-list-graphs
1622 allow:
1623 actors: { group: admins }
1624 actions: [graph_list]
1625"#,
1626 )
1627 .unwrap();
1628 let engine = PolicyEngine::load_server(&path).unwrap();
1629 let decision = engine
1630 .authorize(
1631 "act-andrew",
1632 &PolicyRequest {
1633 action: PolicyAction::GraphList,
1634 branch: None,
1635 target_branch: None,
1636 },
1637 )
1638 .unwrap();
1639 assert!(decision.allowed);
1640 }
1641}