Skip to main content

omnigraph_policy/
lib.rs

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    /// Reserved for **policy-management** surfaces. Per MR-724 Option A,
27    /// this gates operator actions like hot-reloading policy / tokens
28    /// (MR-726), querying the audit log (MR-732), and listing /
29    /// approving pending two-person-rule requests (MR-734). None of
30    /// those endpoints exist yet, so today no engine or HTTP code
31    /// calls `enforce(Admin, ...)`. The variant is kept in the enum so
32    /// the action vocabulary is complete from chassis day one — when
33    /// the first consumer surface ships, it can just call
34    /// `enforce(Admin, ResourceScope::Graph, actor)` without needing
35    /// to add the enum variant + update policy.yaml schemas + redeploy.
36    ///
37    /// Operators can write Cedar rules referencing `admin` today; they
38    /// won't fire (no call site) but they're load-bearing for the
39    /// future shape. Avoid writing such rules until the first consumer
40    /// endpoint ships to prevent confusion.
41    Admin,
42    /// MR-668: management action that operates on the server's graph
43    /// registry, not on a single graph's contents. The Cedar `appliesTo`
44    /// declaration binds it to `resource: Server` instead of the
45    /// per-graph `resource: Graph`. Operators authorize a group with:
46    /// ```yaml
47    /// rules:
48    ///   - id: admins-can-list-graphs
49    ///     allow:
50    ///       actors: { group: admins }
51    ///       actions: [graph_list]
52    /// ```
53    /// `branch_scope` and `target_branch_scope` are NOT supported for
54    /// this action — there's no branch context at the server level.
55    /// Runtime `graph_create` / `graph_delete` are intentionally omitted
56    /// from v0.6.0; operators add and remove graphs by editing
57    /// `omnigraph.yaml` and restarting.
58    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    /// Which Cedar resource entity governs this action.
88    /// Per-graph actions (Read, Change, …) apply to `Omnigraph::Graph::"<id>"`.
89    /// Server-scoped management actions (GraphList) apply to
90    /// `Omnigraph::Server::"root"`. `Admin` is reserved without a current
91    /// call site; classified as per-graph until MR-724 picks a shape.
92    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/// Which Cedar entity an action's policies apply to. Internal to
108/// `omnigraph-policy` — drives the `compile_policy_source` template
109/// and the request-time resource UID construction.
110#[derive(Debug, Clone, Copy, Eq, PartialEq)]
111pub enum PolicyResourceKind {
112    /// `Omnigraph::Graph::"<graph_label>"` — per-graph actions.
113    Graph,
114    /// `Omnigraph::Server::"root"` — management actions.
115    Server,
116}
117
118/// Which kind of policy file the caller is loading. Drives the
119/// load-time validation that catches a "wrong action in wrong file"
120/// mistake — a graph policy with `graph_list` rules, or a server
121/// policy with `read` rules, both compile silently as Cedar but
122/// never match any actual request. Typing the loader makes the
123/// mistake a load-time error.
124///
125/// Pairs with [`PolicyAction::resource_kind`]: every action's resource
126/// kind must match the engine kind it's loaded under.
127#[derive(Debug, Clone, Copy, Eq, PartialEq)]
128pub enum PolicyEngineKind {
129    /// Engine is loaded for a single graph; only actions whose
130    /// `resource_kind()` is `PolicyResourceKind::Graph` are allowed.
131    Graph,
132    /// Engine is loaded for server-level management endpoints; only
133    /// actions whose `resource_kind()` is `PolicyResourceKind::Server`
134    /// are allowed.
135    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/// What a caller wants to do, sans identity. Actor identity flows
226/// through a separate `actor_id: &str` parameter on
227/// [`PolicyEngine::authorize`] / [`PolicyChecker::check`] — encoding
228/// the architectural invariant that actor identity is server-authoritative
229/// and must not be supplied by the same code path that supplies the
230/// requested action. In the HTTP layer, the bearer-token middleware
231/// resolves the actor and passes it independently; clients cannot
232/// smuggle identity inside this struct.
233#[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            // MR-668: server-scoped actions have no branch context and
342            // must not be mixed with per-graph actions in the same
343            // rule (each rule generates one Cedar `permit` referencing
344            // a specific resource kind).
345            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    /// Load a per-graph policy file. Rejects rules whose actions are
438    /// server-scoped (e.g. `graph_list`) — those belong in a server
439    /// policy file, not a per-graph one.
440    ///
441    /// `graph_id` is the label of the graph this engine governs;
442    /// becomes the Cedar `Omnigraph::Graph::"<graph_id>"` resource
443    /// for every per-graph action evaluated against this engine.
444    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    /// Load a server-level policy file. Rejects rules whose actions
451    /// are per-graph (e.g. `read`, `change`) — those belong in a
452    /// per-graph policy file, not the server one. Takes no `graph_id`:
453    /// server-scoped actions resolve against the singleton
454    /// `Omnigraph::Server::"root"` entity, never a Graph.
455    pub fn load_server(path: &Path) -> Result<Self> {
456        let config = PolicyConfig::load(path)?;
457        validate_kind_alignment(&config, PolicyEngineKind::Server)?;
458        // The Graph entity created by the compiler is never referenced
459        // by a server-scoped rule, so the label below is purely a
460        // placeholder. Use the canonical SERVER_RESOURCE_ID so any
461        // future inspection of an unreachable Graph entity at least
462        // points at the right concept.
463        PolicyCompiler::compile(&config, SERVER_RESOURCE_ID)
464    }
465
466    /// Evaluate a request. `actor_id` is supplied as a separate
467    /// argument (not inside `PolicyRequest`) so the type system enforces
468    /// the "server-authoritative actor identity" invariant — clients
469    /// supplying a `PolicyRequest` cannot smuggle identity through the
470    /// same struct that carries the requested action.
471    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        // Pick the resource entity based on the action's `resource_kind`.
485        // Server-scoped actions (`graph_list`) bind to
486        // `Omnigraph::Server::"root"`; per-graph actions bind to
487        // `Omnigraph::Graph::"<graph_label>"`.
488        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
596/// Reject any rule whose actions don't match the engine kind
597/// being loaded. Closes the "wrong action in wrong file silently
598/// no-ops" class — `graph_list` in a per-graph file or `read` in
599/// a server file fails at load time instead of compiling cleanly
600/// and never matching a request.
601fn 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    // MR-668: include the `Omnigraph::Server::"root"` entity
673    // whenever any rule references a server-scoped action. Cedar's
674    // schema validator will otherwise reject the policy. Keeping this
675    // conditional (rather than always-on) avoids polluting test
676    // assertions for graph-only policies.
677    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    // MR-668: emit the resource literal that matches the action's
730    // `resource_kind`. Per-graph actions reference the engine's
731    // `Omnigraph::Graph::"<graph_label>"` instance; server-scoped
732    // actions reference the singleton `Omnigraph::Server::"root"`.
733    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    // MR-668: `entity Server;` plus the `graph_list` action that
781    // binds to it. Per-graph actions stay bound to `Graph`.
782    // The Cedar schema string lives here (not on a fixture file) so any
783    // omnigraph-policy build picks up the new vocabulary in lock-step
784    // with the Rust code.
785    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
815/// Canonical id of the `Omnigraph::Server` Cedar entity. There's only one
816/// (the running server); the id is fixed at `"root"` so Cedar rules can
817/// reference it unambiguously: `resource == Omnigraph::Server::"root"`.
818const 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// ─── PolicyChecker trait + ResourceScope (MR-722 chassis core) ───────────────
845//
846// The trait below is the engine-layer integration point for policy
847// enforcement. `Omnigraph::enforce()` calls `check()` at the head of
848// every mutating method; consumers in the engine crate hold an
849// `Arc<dyn PolicyChecker>` and don't reach into Cedar internals.
850//
851// Two enforcement layers compose via this trait — different methods,
852// same Cedar policies:
853//
854// * **Engine-layer (this trait — `check`)** — coarse gate at operation
855//   entry. Answers "can this actor invoke this action on this scope at all?"
856// * **Query-layer (MR-725 — will add `predicate_for`)** — fine gate
857//   inside the query planner. Answers "for the rows/types touched, which
858//   can the actor see/modify?" Cedar predicates compile to DataFusion
859//   `Expr` and push into the scan.
860//
861// The two layers have non-overlapping responsibilities and must not
862// drift. `ResourceScope` deliberately stays at branch granularity;
863// per-type and per-row scope live in MR-725 via the (future)
864// `predicate_for` method. Do not add `Type(TypeRef)` or `Row(predicate)`
865// variants to `ResourceScope` — that's the boundary the chassis design
866// pins (see MR-722 design refinements comment, 2026-05-17).
867
868/// Resource scope for a policy decision. Branch-grained on purpose —
869/// per-type / per-row granularity is owned by the query-layer (MR-725).
870///
871/// The variants map to today's `(branch, target_branch)` pair convention
872/// in [`PolicyRequest`]. Each writer in the engine picks the variant
873/// that matches how the existing HTTP-layer Cedar policies were
874/// written, so the engine-layer enforce() call and the HTTP-layer
875/// authorize_request() call evaluate the same decision.
876#[derive(Debug, Clone, Eq, PartialEq)]
877pub enum ResourceScope {
878    /// Action applies to the graph as a whole (no branch context).
879    /// Used by graph-level ops if any ever go through enforcement.
880    /// Maps to `(branch: None, target_branch: None)`.
881    Graph,
882    /// Action operates on a single branch — reading from it, writing
883    /// to it, mutating it. Maps to `(branch: Some(X), target_branch: None)`.
884    /// Used by Read, Export, Change.
885    Branch(String),
886    /// Action targets a branch as its destination/effect. The action
887    /// modifies this branch (SchemaApply applies the new schema to it)
888    /// or removes it (BranchDelete). Maps to
889    /// `(branch: None, target_branch: Some(X))`.
890    /// Used by SchemaApply, BranchDelete.
891    TargetBranch(String),
892    /// Action transitions between two branches. `source` is the
893    /// branch being read-from / merged-from / forked-from; `target`
894    /// is the destination. Maps to
895    /// `(branch: Some(source), target_branch: Some(target))`.
896    /// Used by BranchCreate (from→new), BranchMerge (source→target).
897    BranchTransition { source: String, target: String },
898}
899
900impl ResourceScope {
901    /// Lower the scope into the (branch, target_branch) pair carried
902    /// by today's [`PolicyRequest`]. The mapping preserves the
903    /// HTTP-layer's existing scope conventions so Cedar policies don't
904    /// have to be rewritten when engine-layer enforcement is enabled.
905    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/// Engine-layer policy enforcement error. `Denied` is the normal "policy
918/// said no" path; `Internal` covers evaluation failures (malformed rule,
919/// Cedar internal error, etc.).
920#[derive(Debug, Clone)]
921pub enum PolicyError {
922    /// Policy evaluated successfully and denied the action.
923    Denied(String),
924    /// Policy evaluation itself failed (not a denial — a bug or
925    /// configuration error).
926    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
940/// Engine-layer policy enforcement trait. Implemented by `PolicyEngine`
941/// (Cedar-backed) and any mock checker used in tests.
942///
943/// MR-725 will extend this trait with a query-layer pushdown method —
944/// roughly `fn predicate_for(&self, type_ref: &TypeRef, actor: &str) ->
945/// Option<DataFusionExpr>`. Engine and query-layer enforcement back to
946/// the same Cedar policies but consume different methods. Don't conflate
947/// them by overloading `check`.
948pub trait PolicyChecker: Send + Sync {
949    /// Engine-layer gate. Called at the head of every mutating engine
950    /// method. `Ok(())` allows the action; `Err(PolicyError::Denied)`
951    /// denies; `Err(PolicyError::Internal)` reports an evaluation bug.
952    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    // ─── MR-668 — server-scoped action (graph_list) ─
1216
1217    #[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        // The graph_label passed at compile time is irrelevant for
1235        // server-scoped actions — they resolve against
1236        // `Omnigraph::Server::"root"` regardless. We pass a sentinel
1237        // so it's obvious the value isn't used.
1238        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        // Different actor, same policy → deny.
1254        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        // A single rule must reference exactly one resource kind.
1294        // `graph_list` (Server) + `read` (Graph) in one allow block
1295        // is invalid — operators must split the rule.
1296        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        // Decision 6 contract: existing operator policies (which only
1320        // reference per-graph actions) keep compiling and authorizing
1321        // as before, even when the compiled-in schema now declares
1322        // `Server` + `graph_*` actions. This pins the "Cedar refactor
1323        // is operator-invisible" promise.
1324        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    // ─── MR-668 follow-up — load_graph / load_server kind alignment ─
1355
1356    /// A per-graph policy file containing a `graph_list` rule fails
1357    /// at load time. Pre-fix, the file compiled cleanly and the rule
1358    /// silently never matched (per-graph engine never gets a
1359    /// `graph_list` check). Closes the "wrong action, wrong file,
1360    /// silent no-op" class.
1361    #[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    /// A server policy file containing a `read` rule fails at load
1391    /// time. Pre-fix, the file compiled cleanly and the rule silently
1392    /// never matched (server engine never gets a `read` check).
1393    #[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    /// Positive case: a properly-shaped per-graph policy loads via
1424    /// `load_graph` and authorizes as expected. Verifies the
1425    /// kind-alignment check is permissive when the file is correct.
1426    #[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    /// Positive case: a properly-shaped server policy loads via
1460    /// `load_server` and authorizes the `graph_list` action.
1461    #[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}