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    /// Gates invoking a server-side stored query by name. Per-graph and
60    /// **graph-scoped** (no branch dimension, like `Admin`): the per-branch
61    /// access of the query body is enforced by the inner `Read`/`Change`
62    /// gate, so branch-scoping this outer gate would be redundant (and was
63    /// wrong for snapshot reads). A rule that sets `branch_scope` on
64    /// `invoke_query` is rejected by `validate()`. In this release it is
65    /// **coarse**: an `invoke_query` allow rule permits *any* stored query
66    /// on the graph (no per-query dimension yet); a future, additive
67    /// refinement adds an optional query-name scope.
68    ///
69    /// This gate sits at the HTTP boundary. The engine `_as` writers still
70    /// enforce `Read`/`Change` per the query body, so a stored *mutation*
71    /// is double-gated: `invoke_query` to reach the tool, plus `change` for
72    /// the write itself.
73    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    /// Which Cedar resource entity governs this action.
104    /// Per-graph actions (Read, Change, …) apply to `Omnigraph::Graph::"<id>"`.
105    /// Server-scoped management actions (GraphList) apply to
106    /// `Omnigraph::Server::"root"`. `Admin` is reserved without a current
107    /// call site; classified as per-graph until MR-724 picks a shape.
108    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/// Which Cedar entity an action's policies apply to. Internal to
125/// `omnigraph-policy` — drives the `compile_policy_source` template
126/// and the request-time resource UID construction.
127#[derive(Debug, Clone, Copy, Eq, PartialEq)]
128pub enum PolicyResourceKind {
129    /// `Omnigraph::Graph::"<graph_label>"` — per-graph actions.
130    Graph,
131    /// `Omnigraph::Server::"root"` — management actions.
132    Server,
133}
134
135/// Which kind of policy file the caller is loading. Drives the
136/// load-time validation that catches a "wrong action in wrong file"
137/// mistake — a graph policy with `graph_list` rules, or a server
138/// policy with `read` rules, both compile silently as Cedar but
139/// never match any actual request. Typing the loader makes the
140/// mistake a load-time error.
141///
142/// Pairs with [`PolicyAction::resource_kind`]: every action's resource
143/// kind must match the engine kind it's loaded under.
144#[derive(Debug, Clone, Copy, Eq, PartialEq)]
145pub enum PolicyEngineKind {
146    /// Engine is loaded for a single graph; only actions whose
147    /// `resource_kind()` is `PolicyResourceKind::Graph` are allowed.
148    Graph,
149    /// Engine is loaded for server-level management endpoints; only
150    /// actions whose `resource_kind()` is `PolicyResourceKind::Server`
151    /// are allowed.
152    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/// What a caller wants to do, sans identity. Actor identity flows
244/// through a separate `actor_id: &str` parameter on
245/// [`PolicyEngine::authorize`] / [`PolicyChecker::check`] — encoding
246/// the architectural invariant that actor identity is server-authoritative
247/// and must not be supplied by the same code path that supplies the
248/// requested action. In the HTTP layer, the bearer-token middleware
249/// resolves the actor and passes it independently; clients cannot
250/// smuggle identity inside this struct.
251#[derive(Debug, Clone)]
252pub struct PolicyRequest {
253    pub action: PolicyAction,
254    pub branch: Option<String>,
255    pub target_branch: Option<String>,
256}
257
258#[derive(Debug, Clone)]
259pub struct PolicyDecision {
260    pub allowed: bool,
261    pub matched_rule_id: Option<String>,
262    pub message: String,
263}
264
265pub struct PolicyCompiler;
266
267#[derive(Clone)]
268pub struct PolicyEngine {
269    graph_id: String,
270    protected_branches: BTreeSet<String>,
271    known_actors: BTreeSet<String>,
272    schema: Schema,
273    entities: Entities,
274    policies: PolicySet,
275    policy_to_rule: HashMap<String, String>,
276}
277
278impl PolicyConfig {
279    pub fn load(path: &Path) -> Result<Self> {
280        let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
281        config.validate()?;
282        Ok(config)
283    }
284
285    pub fn validate(&self) -> Result<()> {
286        if self.version != 1 {
287            bail!("policy version must be 1");
288        }
289
290        for (group, members) in &self.groups {
291            if group.trim().is_empty() {
292                bail!("policy group names must not be blank");
293            }
294            if members.is_empty() {
295                bail!("policy group '{group}' must not be empty");
296            }
297            for actor in members {
298                if actor.trim().is_empty() {
299                    bail!("policy group '{group}' contains a blank actor id");
300                }
301            }
302        }
303
304        for branch in &self.protected_branches {
305            if branch.trim().is_empty() {
306                bail!("protected branch names must not be blank");
307            }
308        }
309
310        let mut seen_rule_ids = HashSet::new();
311        for rule in &self.rules {
312            if rule.id.trim().is_empty() {
313                bail!("policy rule ids must not be blank");
314            }
315            if !seen_rule_ids.insert(rule.id.clone()) {
316                bail!("duplicate policy rule id '{}'", rule.id);
317            }
318            if rule.allow.actors.group.trim().is_empty() {
319                bail!("policy rule '{}' must reference a non-blank group", rule.id);
320            }
321            if !self.groups.contains_key(rule.allow.actors.group.as_str()) {
322                bail!(
323                    "policy rule '{}' references unknown group '{}'",
324                    rule.id,
325                    rule.allow.actors.group
326                );
327            }
328            if rule.allow.actions.is_empty() {
329                bail!("policy rule '{}' must include at least one action", rule.id);
330            }
331            if rule.allow.branch_scope.is_some() && rule.allow.target_branch_scope.is_some() {
332                bail!(
333                    "policy rule '{}' may specify branch_scope or target_branch_scope, not both",
334                    rule.id
335                );
336            }
337            if let Some(_) = rule.allow.branch_scope {
338                for action in &rule.allow.actions {
339                    if !action.uses_branch_scope() {
340                        bail!(
341                            "policy rule '{}' uses branch_scope with unsupported action '{}'",
342                            rule.id,
343                            action
344                        );
345                    }
346                }
347            }
348            if let Some(_) = rule.allow.target_branch_scope {
349                for action in &rule.allow.actions {
350                    if !action.uses_target_branch_scope() {
351                        bail!(
352                            "policy rule '{}' uses target_branch_scope with unsupported action '{}'",
353                            rule.id,
354                            action
355                        );
356                    }
357                }
358            }
359            // MR-668: server-scoped actions have no branch context and
360            // must not be mixed with per-graph actions in the same
361            // rule (each rule generates one Cedar `permit` referencing
362            // a specific resource kind).
363            let mut server_scoped = false;
364            let mut graph_scoped = false;
365            for action in &rule.allow.actions {
366                match action.resource_kind() {
367                    PolicyResourceKind::Server => server_scoped = true,
368                    PolicyResourceKind::Graph => graph_scoped = true,
369                }
370            }
371            if server_scoped && graph_scoped {
372                bail!(
373                    "policy rule '{}' mixes the server-scoped action `graph_list` \
374                     with per-graph actions; split into separate rules",
375                    rule.id
376                );
377            }
378            if server_scoped
379                && (rule.allow.branch_scope.is_some() || rule.allow.target_branch_scope.is_some())
380            {
381                bail!(
382                    "policy rule '{}' uses branch_scope/target_branch_scope with a \
383                     server-scoped action; server-scoped actions have no branch context",
384                    rule.id
385                );
386            }
387        }
388
389        Ok(())
390    }
391}
392
393impl PolicyTestConfig {
394    pub fn load(path: &Path) -> Result<Self> {
395        let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
396        if config.version != 1 {
397            bail!("policy test version must be 1");
398        }
399        let mut seen = HashSet::new();
400        for case in &config.cases {
401            if case.id.trim().is_empty() {
402                bail!("policy test case ids must not be blank");
403            }
404            if !seen.insert(case.id.clone()) {
405                bail!("duplicate policy test case id '{}'", case.id);
406            }
407            if case.actor.trim().is_empty() {
408                bail!("policy test case '{}' must not use a blank actor", case.id);
409            }
410        }
411        Ok(config)
412    }
413}
414
415impl PolicyCompiler {
416    pub fn compile(config: &PolicyConfig, graph_id: &str) -> Result<PolicyEngine> {
417        config.validate()?;
418        let (schema, schema_warnings) = Schema::from_cedarschema_str(policy_schema_source())?;
419        let schema_warnings = schema_warnings
420            .map(|warning| warning.to_string())
421            .collect::<Vec<_>>();
422        if !schema_warnings.is_empty() {
423            bail!("policy schema warnings:\n{}", schema_warnings.join("\n"));
424        }
425        let entities = compile_entities(config, graph_id, &schema)?;
426        let (policies, policy_to_rule) = compile_policies(config, graph_id)?;
427        let validator = Validator::new(schema.clone());
428        let validation = validator.validate(&policies, ValidationMode::Strict);
429        let errors = validation
430            .validation_errors()
431            .map(|err| err.to_string())
432            .collect::<Vec<_>>();
433        if !errors.is_empty() {
434            bail!("policy validation failed:\n{}", errors.join("\n"));
435        }
436
437        let known_actors = config
438            .groups
439            .values()
440            .flat_map(|members| members.iter().cloned())
441            .collect();
442        Ok(PolicyEngine {
443            graph_id: graph_id.to_string(),
444            protected_branches: config.protected_branches.iter().cloned().collect(),
445            known_actors,
446            schema,
447            entities,
448            policies,
449            policy_to_rule,
450        })
451    }
452}
453
454impl PolicyEngine {
455    /// Load a per-graph policy file. Rejects rules whose actions are
456    /// server-scoped (e.g. `graph_list`) — those belong in a server
457    /// policy file, not a per-graph one.
458    ///
459    /// `graph_id` is the label of the graph this engine governs;
460    /// becomes the Cedar `Omnigraph::Graph::"<graph_id>"` resource
461    /// for every per-graph action evaluated against this engine.
462    pub fn load_graph(path: &Path, graph_id: &str) -> Result<Self> {
463        let config = PolicyConfig::load(path)?;
464        validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
465        PolicyCompiler::compile(&config, graph_id)
466    }
467
468    /// Load a server-level policy file. Rejects rules whose actions
469    /// are per-graph (e.g. `read`, `change`) — those belong in a
470    /// per-graph policy file, not the server one. Takes no `graph_id`:
471    /// server-scoped actions resolve against the singleton
472    /// `Omnigraph::Server::"root"` entity, never a Graph.
473    pub fn load_server(path: &Path) -> Result<Self> {
474        let config = PolicyConfig::load(path)?;
475        validate_kind_alignment(&config, PolicyEngineKind::Server)?;
476        // The Graph entity created by the compiler is never referenced
477        // by a server-scoped rule, so the label below is purely a
478        // placeholder. Use the canonical SERVER_RESOURCE_ID so any
479        // future inspection of an unreachable Graph entity at least
480        // points at the right concept.
481        PolicyCompiler::compile(&config, SERVER_RESOURCE_ID)
482    }
483
484    /// Evaluate a request. `actor_id` is supplied as a separate
485    /// argument (not inside `PolicyRequest`) so the type system enforces
486    /// the "server-authoritative actor identity" invariant — clients
487    /// supplying a `PolicyRequest` cannot smuggle identity through the
488    /// same struct that carries the requested action.
489    pub fn authorize(&self, actor_id: &str, request: &PolicyRequest) -> Result<PolicyDecision> {
490        if !self.known_actors.contains(actor_id) {
491            return Ok(self.deny(
492                None,
493                format!(
494                    "policy denied action '{}' for unknown actor '{}'",
495                    request.action, actor_id
496                ),
497            ));
498        }
499
500        let principal = entity_uid("Actor", actor_id)?;
501        let action = entity_uid("Action", request.action.as_str())?;
502        // Pick the resource entity based on the action's `resource_kind`.
503        // Server-scoped actions (`graph_list`) bind to
504        // `Omnigraph::Server::"root"`; per-graph actions bind to
505        // `Omnigraph::Graph::"<graph_label>"`.
506        let resource = match request.action.resource_kind() {
507            PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?,
508            PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?,
509        };
510        let context_value = json!({
511            "has_branch": request.branch.is_some(),
512            "branch": request.branch.clone().unwrap_or_default(),
513            "has_target_branch": request.target_branch.is_some(),
514            "target_branch": request.target_branch.clone().unwrap_or_default(),
515            "branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
516            "target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
517        });
518        let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
519        let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
520        let response =
521            Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
522        let errors = response
523            .diagnostics()
524            .errors()
525            .map(|err| err.to_string())
526            .collect::<Vec<_>>();
527        if !errors.is_empty() {
528            bail!("policy evaluation failed:\n{}", errors.join("\n"));
529        }
530
531        let matched_rule_id = response
532            .diagnostics()
533            .reason()
534            .filter_map(|policy_id| {
535                let key: &str = policy_id.as_ref();
536                self.policy_to_rule.get(key).cloned()
537            })
538            .min();
539
540        Ok(match response.decision() {
541            Decision::Allow => PolicyDecision {
542                allowed: true,
543                matched_rule_id: matched_rule_id.clone(),
544                message: format!(
545                    "policy allowed action '{}' for actor '{}'",
546                    request.action, actor_id
547                ),
548            },
549            Decision::Deny => {
550                let message = format!(
551                    "policy denied action '{}'{}{} for actor '{}'",
552                    request.action,
553                    request
554                        .branch
555                        .as_deref()
556                        .map(|branch| format!(" on branch '{}'", branch))
557                        .unwrap_or_default(),
558                    request
559                        .target_branch
560                        .as_deref()
561                        .map(|branch| format!(" targeting branch '{}'", branch))
562                        .unwrap_or_default(),
563                    actor_id
564                );
565                self.deny(matched_rule_id, message)
566            }
567        })
568    }
569
570    pub fn run_tests(&self, tests: &PolicyTestConfig) -> Result<()> {
571        if tests.version != 1 {
572            bail!("policy test version must be 1");
573        }
574        let mut failures = Vec::new();
575        for case in &tests.cases {
576            let decision = self.authorize(
577                &case.actor,
578                &PolicyRequest {
579                    action: case.action,
580                    branch: case.branch.clone(),
581                    target_branch: case.target_branch.clone(),
582                },
583            )?;
584            let expected_allowed = matches!(case.expect, PolicyExpectation::Allow);
585            if decision.allowed != expected_allowed {
586                failures.push(format!(
587                    "{}: expected {:?} but got {}",
588                    case.id,
589                    case.expect,
590                    if decision.allowed { "allow" } else { "deny" }
591                ));
592            }
593        }
594        if failures.is_empty() {
595            Ok(())
596        } else {
597            bail!("policy tests failed:\n{}", failures.join("\n"))
598        }
599    }
600
601    pub fn known_actor_count(&self) -> usize {
602        self.known_actors.len()
603    }
604
605    fn deny(&self, matched_rule_id: Option<String>, message: String) -> PolicyDecision {
606        PolicyDecision {
607            allowed: false,
608            matched_rule_id,
609            message,
610        }
611    }
612}
613
614/// Reject any rule whose actions don't match the engine kind
615/// being loaded. Closes the "wrong action in wrong file silently
616/// no-ops" class — `graph_list` in a per-graph file or `read` in
617/// a server file fails at load time instead of compiling cleanly
618/// and never matching a request.
619fn validate_kind_alignment(config: &PolicyConfig, kind: PolicyEngineKind) -> Result<()> {
620    let required = match kind {
621        PolicyEngineKind::Graph => PolicyResourceKind::Graph,
622        PolicyEngineKind::Server => PolicyResourceKind::Server,
623    };
624    for rule in &config.rules {
625        for action in &rule.allow.actions {
626            if action.resource_kind() != required {
627                let (got, expected_file) = match action.resource_kind() {
628                    PolicyResourceKind::Server => ("server-scoped", "server policy file"),
629                    PolicyResourceKind::Graph => ("per-graph", "per-graph policy file"),
630                };
631                bail!(
632                    "policy rule '{}' uses {} action '{}' in a {:?} policy file; \
633                     move it to a {}",
634                    rule.id,
635                    got,
636                    action,
637                    kind,
638                    expected_file
639                );
640            }
641        }
642    }
643    Ok(())
644}
645
646fn compile_entities(config: &PolicyConfig, graph_id: &str, schema: &Schema) -> Result<Entities> {
647    let mut group_entities = Vec::new();
648    for group in config.groups.keys() {
649        group_entities.push(Entity::new(
650            entity_uid("Group", group)?,
651            HashMap::new(),
652            HashSet::<EntityUid>::new(),
653        )?);
654    }
655
656    let mut actor_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
657    for (group, members) in &config.groups {
658        for actor in members {
659            actor_groups
660                .entry(actor.clone())
661                .or_default()
662                .insert(group.clone());
663        }
664    }
665
666    let mut actor_entities = Vec::new();
667    for (actor, groups) in actor_groups {
668        let parents = groups
669            .iter()
670            .map(|group| entity_uid("Group", group))
671            .collect::<Result<HashSet<_>>>()?;
672        actor_entities.push(Entity::new(
673            entity_uid("Actor", &actor)?,
674            HashMap::new(),
675            parents,
676        )?);
677    }
678
679    let graph_entity = Entity::new(
680        entity_uid("Graph", graph_id)?,
681        HashMap::new(),
682        HashSet::<EntityUid>::new(),
683    )?;
684
685    let mut entities = Vec::new();
686    entities.extend(group_entities);
687    entities.extend(actor_entities);
688    entities.push(graph_entity);
689
690    // MR-668: include the `Omnigraph::Server::"root"` entity
691    // whenever any rule references a server-scoped action. Cedar's
692    // schema validator will otherwise reject the policy. Keeping this
693    // conditional (rather than always-on) avoids polluting test
694    // assertions for graph-only policies.
695    let any_server_scoped = config.rules.iter().any(|rule| {
696        rule.allow
697            .actions
698            .iter()
699            .any(|action| action.resource_kind() == PolicyResourceKind::Server)
700    });
701    if any_server_scoped {
702        entities.push(Entity::new(
703            entity_uid("Server", SERVER_RESOURCE_ID)?,
704            HashMap::new(),
705            HashSet::<EntityUid>::new(),
706        )?);
707    }
708
709    Ok(Entities::from_entities(entities, Some(schema))?)
710}
711
712fn compile_policies(
713    config: &PolicyConfig,
714    graph_id: &str,
715) -> Result<(PolicySet, HashMap<String, String>)> {
716    let mut policies = Vec::new();
717    let mut policy_to_rule = HashMap::new();
718
719    for rule in &config.rules {
720        for action in &rule.allow.actions {
721            let policy_id = PolicyId::new(format!("{}:{}", rule.id, action.as_str()));
722            let source = compile_policy_source(rule, action, graph_id);
723            let policy = Policy::parse(Some(policy_id.clone()), source.as_str())?;
724            policy_to_rule.insert(policy_id.to_string(), rule.id.clone());
725            policies.push(policy);
726        }
727    }
728
729    Ok((PolicySet::from_policies(policies)?, policy_to_rule))
730}
731
732fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &str) -> String {
733    let mut conditions = Vec::new();
734    if let Some(scope) = rule.allow.branch_scope {
735        conditions.push(branch_scope_condition(scope));
736    }
737    if let Some(scope) = rule.allow.target_branch_scope {
738        conditions.push(target_branch_scope_condition(scope));
739    }
740
741    let when = if conditions.is_empty() {
742        String::new()
743    } else {
744        format!("\nwhen {{ {} }}", conditions.join(" && "))
745    };
746
747    // MR-668: emit the resource literal that matches the action's
748    // `resource_kind`. Per-graph actions reference the engine's
749    // `Omnigraph::Graph::"<graph_label>"` instance; server-scoped
750    // actions reference the singleton `Omnigraph::Server::"root"`.
751    let resource_literal = match action.resource_kind() {
752        PolicyResourceKind::Graph => {
753            format!("Omnigraph::Graph::{}", cedar_literal(graph_id))
754        }
755        PolicyResourceKind::Server => {
756            format!("Omnigraph::Server::{}", cedar_literal(SERVER_RESOURCE_ID))
757        }
758    };
759
760    format!(
761        r#"permit (
762    principal in Omnigraph::Group::{group},
763    action == Omnigraph::Action::{action},
764    resource == {resource_literal}
765){when};"#,
766        group = cedar_literal(&rule.allow.actors.group),
767        action = cedar_literal(action.as_str()),
768        when = when,
769        resource_literal = resource_literal,
770    )
771}
772
773fn branch_scope_condition(scope: PolicyBranchScope) -> String {
774    match scope {
775        PolicyBranchScope::Any => "true".to_string(),
776        PolicyBranchScope::Protected => {
777            "context.has_branch && context.branch_is_protected".to_string()
778        }
779        PolicyBranchScope::Unprotected => {
780            "context.has_branch && context.branch_is_protected == false".to_string()
781        }
782    }
783}
784
785fn target_branch_scope_condition(scope: PolicyBranchScope) -> String {
786    match scope {
787        PolicyBranchScope::Any => "true".to_string(),
788        PolicyBranchScope::Protected => {
789            "context.has_target_branch && context.target_branch_is_protected".to_string()
790        }
791        PolicyBranchScope::Unprotected => {
792            "context.has_target_branch && context.target_branch_is_protected == false".to_string()
793        }
794    }
795}
796
797fn policy_schema_source() -> &'static str {
798    // MR-668: `entity Server;` plus the `graph_list` action that
799    // binds to it. Per-graph actions stay bound to `Graph`.
800    // The Cedar schema string lives here (not on a fixture file) so any
801    // omnigraph-policy build picks up the new vocabulary in lock-step
802    // with the Rust code.
803    r#"
804namespace Omnigraph {
805    type RequestContext = {
806        has_branch: Bool,
807        branch: String,
808        has_target_branch: Bool,
809        target_branch: String,
810        branch_is_protected: Bool,
811        target_branch_is_protected: Bool,
812    };
813
814    entity Actor in [Group];
815    entity Group;
816    entity Graph;
817    entity Server;
818
819    action "read" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
820    action "export" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
821    action "change" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
822    action "schema_apply" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
823    action "branch_create" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
824    action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
825    action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
826    action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
827    action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
828
829    action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext };
830}
831"#
832}
833
834/// Canonical id of the `Omnigraph::Server` Cedar entity. There's only one
835/// (the running server); the id is fixed at `"root"` so Cedar rules can
836/// reference it unambiguously: `resource == Omnigraph::Server::"root"`.
837const SERVER_RESOURCE_ID: &str = "root";
838
839fn entity_uid(entity_type: &str, id: &str) -> Result<EntityUid> {
840    let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?;
841    let entity_id = EntityId::from_str(id).map_err(|err| eyre!(err.to_string()))?;
842    Ok(EntityUid::from_type_name_and_id(typename, entity_id))
843}
844
845fn cedar_literal(value: &str) -> String {
846    serde_json::to_string(value).expect("string literal should serialize")
847}
848
849impl PolicyRequest {
850    pub fn action(&self) -> PolicyAction {
851        self.action
852    }
853
854    pub fn branch(&self) -> Option<&str> {
855        self.branch.as_deref()
856    }
857
858    pub fn target_branch(&self) -> Option<&str> {
859        self.target_branch.as_deref()
860    }
861}
862
863// ─── PolicyChecker trait + ResourceScope (MR-722 chassis core) ───────────────
864//
865// The trait below is the engine-layer integration point for policy
866// enforcement. `Omnigraph::enforce()` calls `check()` at the head of
867// every mutating method; consumers in the engine crate hold an
868// `Arc<dyn PolicyChecker>` and don't reach into Cedar internals.
869//
870// Two enforcement layers compose via this trait — different methods,
871// same Cedar policies:
872//
873// * **Engine-layer (this trait — `check`)** — coarse gate at operation
874//   entry. Answers "can this actor invoke this action on this scope at all?"
875// * **Query-layer (MR-725 — will add `predicate_for`)** — fine gate
876//   inside the query planner. Answers "for the rows/types touched, which
877//   can the actor see/modify?" Cedar predicates compile to DataFusion
878//   `Expr` and push into the scan.
879//
880// The two layers have non-overlapping responsibilities and must not
881// drift. `ResourceScope` deliberately stays at branch granularity;
882// per-type and per-row scope live in MR-725 via the (future)
883// `predicate_for` method. Do not add `Type(TypeRef)` or `Row(predicate)`
884// variants to `ResourceScope` — that's the boundary the chassis design
885// pins (see MR-722 design refinements comment, 2026-05-17).
886
887/// Resource scope for a policy decision. Branch-grained on purpose —
888/// per-type / per-row granularity is owned by the query-layer (MR-725).
889///
890/// The variants map to today's `(branch, target_branch)` pair convention
891/// in [`PolicyRequest`]. Each writer in the engine picks the variant
892/// that matches how the existing HTTP-layer Cedar policies were
893/// written, so the engine-layer enforce() call and the HTTP-layer
894/// authorize_request() call evaluate the same decision.
895#[derive(Debug, Clone, Eq, PartialEq)]
896pub enum ResourceScope {
897    /// Action applies to the graph as a whole (no branch context).
898    /// Used by graph-level ops if any ever go through enforcement.
899    /// Maps to `(branch: None, target_branch: None)`.
900    Graph,
901    /// Action operates on a single branch — reading from it, writing
902    /// to it, mutating it. Maps to `(branch: Some(X), target_branch: None)`.
903    /// Used by Read, Export, Change.
904    Branch(String),
905    /// Action targets a branch as its destination/effect. The action
906    /// modifies this branch (SchemaApply applies the new schema to it)
907    /// or removes it (BranchDelete). Maps to
908    /// `(branch: None, target_branch: Some(X))`.
909    /// Used by SchemaApply, BranchDelete.
910    TargetBranch(String),
911    /// Action transitions between two branches. `source` is the
912    /// branch being read-from / merged-from / forked-from; `target`
913    /// is the destination. Maps to
914    /// `(branch: Some(source), target_branch: Some(target))`.
915    /// Used by BranchCreate (from→new), BranchMerge (source→target).
916    BranchTransition { source: String, target: String },
917}
918
919impl ResourceScope {
920    /// Lower the scope into the (branch, target_branch) pair carried
921    /// by today's [`PolicyRequest`]. The mapping preserves the
922    /// HTTP-layer's existing scope conventions so Cedar policies don't
923    /// have to be rewritten when engine-layer enforcement is enabled.
924    pub fn to_branch_pair(&self) -> (Option<&str>, Option<&str>) {
925        match self {
926            ResourceScope::Graph => (None, None),
927            ResourceScope::Branch(branch) => (Some(branch.as_str()), None),
928            ResourceScope::TargetBranch(target) => (None, Some(target.as_str())),
929            ResourceScope::BranchTransition { source, target } => {
930                (Some(source.as_str()), Some(target.as_str()))
931            }
932        }
933    }
934}
935
936/// Engine-layer policy enforcement error. `Denied` is the normal "policy
937/// said no" path; `Internal` covers evaluation failures (malformed rule,
938/// Cedar internal error, etc.).
939#[derive(Debug, Clone)]
940pub enum PolicyError {
941    /// Policy evaluated successfully and denied the action.
942    Denied(String),
943    /// Policy evaluation itself failed (not a denial — a bug or
944    /// configuration error).
945    Internal(String),
946}
947
948impl fmt::Display for PolicyError {
949    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
950        match self {
951            PolicyError::Denied(msg) => write!(f, "policy denied: {msg}"),
952            PolicyError::Internal(msg) => write!(f, "policy evaluation failed: {msg}"),
953        }
954    }
955}
956
957impl std::error::Error for PolicyError {}
958
959/// Engine-layer policy enforcement trait. Implemented by `PolicyEngine`
960/// (Cedar-backed) and any mock checker used in tests.
961///
962/// MR-725 will extend this trait with a query-layer pushdown method —
963/// roughly `fn predicate_for(&self, type_ref: &TypeRef, actor: &str) ->
964/// Option<DataFusionExpr>`. Engine and query-layer enforcement back to
965/// the same Cedar policies but consume different methods. Don't conflate
966/// them by overloading `check`.
967pub trait PolicyChecker: Send + Sync {
968    /// Engine-layer gate. Called at the head of every mutating engine
969    /// method. `Ok(())` allows the action; `Err(PolicyError::Denied)`
970    /// denies; `Err(PolicyError::Internal)` reports an evaluation bug.
971    fn check(
972        &self,
973        action: PolicyAction,
974        scope: &ResourceScope,
975        actor: &str,
976    ) -> Result<(), PolicyError>;
977}
978
979impl PolicyChecker for PolicyEngine {
980    fn check(
981        &self,
982        action: PolicyAction,
983        scope: &ResourceScope,
984        actor: &str,
985    ) -> Result<(), PolicyError> {
986        let (branch, target_branch) = scope.to_branch_pair();
987        let request = PolicyRequest {
988            action,
989            branch: branch.map(|s| s.to_string()),
990            target_branch: target_branch.map(|s| s.to_string()),
991        };
992        let decision = self
993            .authorize(actor, &request)
994            .map_err(|e| PolicyError::Internal(e.to_string()))?;
995        if decision.allowed {
996            Ok(())
997        } else {
998            Err(PolicyError::Denied(decision.message))
999        }
1000    }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::{
1006        PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest,
1007        PolicyTestCase, PolicyTestConfig,
1008    };
1009
1010    #[test]
1011    fn rejects_duplicate_rule_ids() {
1012        let policy: PolicyConfig = serde_yaml::from_str(
1013            r#"
1014version: 1
1015groups:
1016  team: [act-andrew]
1017rules:
1018  - id: same
1019    allow:
1020      actors: { group: team }
1021      actions: [read]
1022      branch_scope: any
1023  - id: same
1024    allow:
1025      actors: { group: team }
1026      actions: [export]
1027      branch_scope: any
1028"#,
1029        )
1030        .unwrap();
1031
1032        let err = policy.validate().unwrap_err();
1033        assert!(err.to_string().contains("duplicate policy rule id"));
1034    }
1035
1036    #[test]
1037    fn rejects_unknown_group_references() {
1038        let policy: PolicyConfig = serde_yaml::from_str(
1039            r#"
1040version: 1
1041groups:
1042  team: [act-andrew]
1043rules:
1044  - id: bad
1045    allow:
1046      actors: { group: admins }
1047      actions: [read]
1048      branch_scope: any
1049"#,
1050        )
1051        .unwrap();
1052
1053        let err = policy.validate().unwrap_err();
1054        assert!(err.to_string().contains("references unknown group"));
1055    }
1056
1057    #[test]
1058    fn rejects_invalid_scope_action_combinations() {
1059        let policy: PolicyConfig = serde_yaml::from_str(
1060            r#"
1061version: 1
1062groups:
1063  team: [act-andrew]
1064rules:
1065  - id: bad
1066    allow:
1067      actors: { group: team }
1068      actions: [branch_merge]
1069      branch_scope: protected
1070"#,
1071        )
1072        .unwrap();
1073
1074        let err = policy.validate().unwrap_err();
1075        assert!(err.to_string().contains("unsupported action"));
1076    }
1077
1078    #[test]
1079    fn compiles_and_authorizes_branch_and_target_rules() {
1080        let policy: PolicyConfig = serde_yaml::from_str(
1081            r#"
1082version: 1
1083groups:
1084  team: [act-andrew, act-bruno]
1085  admins: [act-andrew]
1086protected_branches: [main]
1087rules:
1088  - id: team-read
1089    allow:
1090      actors: { group: team }
1091      actions: [read, export]
1092      branch_scope: any
1093  - id: team-write
1094    allow:
1095      actors: { group: team }
1096      actions: [change]
1097      branch_scope: unprotected
1098  - id: admins-promote
1099    allow:
1100      actors: { group: admins }
1101      actions: [branch_delete, branch_merge]
1102      target_branch_scope: protected
1103"#,
1104        )
1105        .unwrap();
1106
1107        let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1108        let allow = engine
1109            .authorize(
1110                "act-bruno",
1111                &PolicyRequest {
1112                    action: PolicyAction::Change,
1113                    branch: Some("feature".to_string()),
1114                    target_branch: None,
1115                },
1116            )
1117            .unwrap();
1118        assert!(allow.allowed);
1119        assert_eq!(allow.matched_rule_id.as_deref(), Some("team-write"));
1120
1121        let deny = engine
1122            .authorize(
1123                "act-bruno",
1124                &PolicyRequest {
1125                    action: PolicyAction::BranchDelete,
1126                    branch: None,
1127                    target_branch: Some("main".to_string()),
1128                },
1129            )
1130            .unwrap();
1131        assert!(!deny.allowed);
1132
1133        let admin = engine
1134            .authorize(
1135                "act-andrew",
1136                &PolicyRequest {
1137                    action: PolicyAction::BranchDelete,
1138                    branch: None,
1139                    target_branch: Some("main".to_string()),
1140                },
1141            )
1142            .unwrap();
1143        assert!(admin.allowed);
1144        assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
1145    }
1146
1147    #[test]
1148    fn policy_tests_enforce_expected_outcomes() {
1149        let policy: PolicyConfig = serde_yaml::from_str(
1150            r#"
1151version: 1
1152groups:
1153  team: [act-andrew]
1154protected_branches: [main]
1155rules:
1156  - id: team-read
1157    allow:
1158      actors: { group: team }
1159      actions: [read]
1160      branch_scope: any
1161"#,
1162        )
1163        .unwrap();
1164        let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1165        let tests = PolicyTestConfig {
1166            version: 1,
1167            cases: vec![
1168                PolicyTestCase {
1169                    id: "allow-read".to_string(),
1170                    actor: "act-andrew".to_string(),
1171                    action: PolicyAction::Read,
1172                    branch: Some("main".to_string()),
1173                    target_branch: None,
1174                    expect: PolicyExpectation::Allow,
1175                },
1176                PolicyTestCase {
1177                    id: "deny-change".to_string(),
1178                    actor: "act-andrew".to_string(),
1179                    action: PolicyAction::Change,
1180                    branch: Some("main".to_string()),
1181                    target_branch: None,
1182                    expect: PolicyExpectation::Deny,
1183                },
1184            ],
1185        };
1186
1187        engine.run_tests(&tests).unwrap();
1188    }
1189
1190    #[test]
1191    fn schema_apply_uses_target_branch_scope() {
1192        let policy: PolicyConfig = serde_yaml::from_str(
1193            r#"
1194version: 1
1195groups:
1196  admins: [act-ragnor]
1197protected_branches: [main]
1198rules:
1199  - id: admins-schema-apply
1200    allow:
1201      actors: { group: admins }
1202      actions: [schema_apply]
1203      target_branch_scope: protected
1204"#,
1205        )
1206        .unwrap();
1207
1208        let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1209        let allow = engine
1210            .authorize(
1211                "act-ragnor",
1212                &PolicyRequest {
1213                    action: PolicyAction::SchemaApply,
1214                    branch: None,
1215                    target_branch: Some("main".to_string()),
1216                },
1217            )
1218            .unwrap();
1219        assert!(allow.allowed);
1220
1221        let deny = engine
1222            .authorize(
1223                "act-ragnor",
1224                &PolicyRequest {
1225                    action: PolicyAction::SchemaApply,
1226                    branch: None,
1227                    target_branch: Some("feature".to_string()),
1228                },
1229            )
1230            .unwrap();
1231        assert!(!deny.allowed);
1232    }
1233
1234    // ─── MR-668 — server-scoped action (graph_list) ─
1235
1236    #[test]
1237    fn graph_list_action_authorizes_against_server_resource() {
1238        let policy: PolicyConfig = serde_yaml::from_str(
1239            r#"
1240version: 1
1241groups:
1242  admins: [act-andrew]
1243  viewers: [act-bruno]
1244rules:
1245  - id: admins-list-graphs
1246    allow:
1247      actors: { group: admins }
1248      actions: [graph_list]
1249"#,
1250        )
1251        .unwrap();
1252
1253        // The graph_label passed at compile time is irrelevant for
1254        // server-scoped actions — they resolve against
1255        // `Omnigraph::Server::"root"` regardless. We pass a sentinel
1256        // so it's obvious the value isn't used.
1257        let engine = PolicyCompiler::compile(&policy, "ignored").unwrap();
1258
1259        let allow = engine
1260            .authorize(
1261                "act-andrew",
1262                &PolicyRequest {
1263                    action: PolicyAction::GraphList,
1264                    branch: None,
1265                    target_branch: None,
1266                },
1267            )
1268            .unwrap();
1269        assert!(allow.allowed);
1270        assert_eq!(allow.matched_rule_id.as_deref(), Some("admins-list-graphs"));
1271
1272        // Different actor, same policy → deny.
1273        let deny = engine
1274            .authorize(
1275                "act-bruno",
1276                &PolicyRequest {
1277                    action: PolicyAction::GraphList,
1278                    branch: None,
1279                    target_branch: None,
1280                },
1281            )
1282            .unwrap();
1283        assert!(!deny.allowed);
1284    }
1285
1286    #[test]
1287    fn invoke_query_authorizes_per_graph() {
1288        let policy: PolicyConfig = serde_yaml::from_str(
1289            r#"
1290version: 1
1291groups:
1292  team: [act-alice]
1293  others: [act-bruno]
1294rules:
1295  - id: team-invoke-queries
1296    allow:
1297      actors: { group: team }
1298      actions: [invoke_query]
1299"#,
1300        )
1301        .unwrap();
1302        let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1303
1304        let allow = engine
1305            .authorize(
1306                "act-alice",
1307                &PolicyRequest {
1308                    action: PolicyAction::InvokeQuery,
1309                    branch: None,
1310                    target_branch: None,
1311                },
1312            )
1313            .unwrap();
1314        assert!(allow.allowed);
1315        assert_eq!(
1316            allow.matched_rule_id.as_deref(),
1317            Some("team-invoke-queries")
1318        );
1319
1320        // Actor outside the group → deny.
1321        let deny = engine
1322            .authorize(
1323                "act-bruno",
1324                &PolicyRequest {
1325                    action: PolicyAction::InvokeQuery,
1326                    branch: None,
1327                    target_branch: None,
1328                },
1329            )
1330            .unwrap();
1331        assert!(!deny.allowed);
1332    }
1333
1334    #[test]
1335    fn invoke_query_rejects_branch_scope() {
1336        // invoke_query is graph-scoped (like admin) — per-branch access is
1337        // enforced by the inner read/change gate — so a rule that puts a
1338        // `branch_scope` qualifier on it is rejected at validate().
1339        let policy: PolicyConfig = serde_yaml::from_str(
1340            r#"
1341version: 1
1342groups:
1343  team: [act-alice]
1344rules:
1345  - id: team-invoke-any-branch
1346    allow:
1347      actors: { group: team }
1348      actions: [invoke_query]
1349      branch_scope: any
1350"#,
1351        )
1352        .unwrap();
1353        let err = policy.validate().unwrap_err().to_string();
1354        assert!(
1355            err.contains("branch_scope") && err.contains("invoke_query"),
1356            "branch_scope on invoke_query must be rejected: {err}"
1357        );
1358    }
1359
1360    #[test]
1361    fn server_scoped_rule_cannot_use_branch_scope() {
1362        let policy: PolicyConfig = serde_yaml::from_str(
1363            r#"
1364version: 1
1365groups:
1366  admins: [act-andrew]
1367rules:
1368  - id: bad-branch-scope-on-graph-list
1369    allow:
1370      actors: { group: admins }
1371      actions: [graph_list]
1372      branch_scope: any
1373"#,
1374        )
1375        .unwrap();
1376        let err = policy.validate().unwrap_err();
1377        let msg = err.to_string();
1378        assert!(
1379            msg.contains("branch_scope") || msg.contains("server-scoped"),
1380            "expected branch_scope rejection for server-scoped action; got: {msg}"
1381        );
1382    }
1383
1384    #[test]
1385    fn rule_mixing_server_and_per_graph_actions_is_rejected() {
1386        // A single rule must reference exactly one resource kind.
1387        // `graph_list` (Server) + `read` (Graph) in one allow block
1388        // is invalid — operators must split the rule.
1389        let policy: PolicyConfig = serde_yaml::from_str(
1390            r#"
1391version: 1
1392groups:
1393  admins: [act-andrew]
1394rules:
1395  - id: mixed-resource-kinds
1396    allow:
1397      actors: { group: admins }
1398      actions: [graph_list, read]
1399"#,
1400        )
1401        .unwrap();
1402        let err = policy.validate().unwrap_err();
1403        let msg = err.to_string();
1404        assert!(
1405            msg.contains("server-scoped") || msg.contains("split into separate rules"),
1406            "expected mix-resource-kinds rejection; got: {msg}"
1407        );
1408    }
1409
1410    #[test]
1411    fn per_graph_rules_continue_to_work_alongside_server_rules() {
1412        // Decision 6 contract: existing operator policies (which only
1413        // reference per-graph actions) keep compiling and authorizing
1414        // as before, even when the compiled-in schema now declares
1415        // `Server` + `graph_*` actions. This pins the "Cedar refactor
1416        // is operator-invisible" promise.
1417        let policy: PolicyConfig = serde_yaml::from_str(
1418            r#"
1419version: 1
1420groups:
1421  team: [act-andrew]
1422protected_branches: [main]
1423rules:
1424  - id: team-read
1425    allow:
1426      actors: { group: team }
1427      actions: [read, export]
1428      branch_scope: any
1429"#,
1430        )
1431        .unwrap();
1432        let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
1433        let allow = engine
1434            .authorize(
1435                "act-andrew",
1436                &PolicyRequest {
1437                    action: PolicyAction::Read,
1438                    branch: Some("main".to_string()),
1439                    target_branch: None,
1440                },
1441            )
1442            .unwrap();
1443        assert!(allow.allowed);
1444        assert_eq!(allow.matched_rule_id.as_deref(), Some("team-read"));
1445    }
1446
1447    // ─── MR-668 follow-up — load_graph / load_server kind alignment ─
1448
1449    /// A per-graph policy file containing a `graph_list` rule fails
1450    /// at load time. Pre-fix, the file compiled cleanly and the rule
1451    /// silently never matched (per-graph engine never gets a
1452    /// `graph_list` check). Closes the "wrong action, wrong file,
1453    /// silent no-op" class.
1454    #[test]
1455    fn load_graph_rejects_server_scoped_action() {
1456        let dir = tempfile::tempdir().unwrap();
1457        let path = dir.path().join("bad-graph-policy.yaml");
1458        std::fs::write(
1459            &path,
1460            r#"
1461version: 1
1462groups:
1463  admins: [act-andrew]
1464rules:
1465  - id: misplaced-graph-list
1466    allow:
1467      actors: { group: admins }
1468      actions: [graph_list]
1469"#,
1470        )
1471        .unwrap();
1472        let err = match PolicyEngine::load_graph(&path, "g1") {
1473            Ok(_) => panic!("expected server-scoped action in per-graph file to be rejected"),
1474            Err(e) => e,
1475        };
1476        let msg = err.to_string();
1477        assert!(
1478            msg.contains("server-scoped") && msg.contains("graph_list"),
1479            "expected server-scoped-in-graph-file rejection, got: {msg}"
1480        );
1481    }
1482
1483    /// A server policy file containing a `read` rule fails at load
1484    /// time. Pre-fix, the file compiled cleanly and the rule silently
1485    /// never matched (server engine never gets a `read` check).
1486    #[test]
1487    fn load_server_rejects_per_graph_action() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let path = dir.path().join("bad-server-policy.yaml");
1490        std::fs::write(
1491            &path,
1492            r#"
1493version: 1
1494groups:
1495  team: [act-andrew]
1496rules:
1497  - id: misplaced-read
1498    allow:
1499      actors: { group: team }
1500      actions: [read]
1501      branch_scope: any
1502"#,
1503        )
1504        .unwrap();
1505        let err = match PolicyEngine::load_server(&path) {
1506            Ok(_) => panic!("expected per-graph action in server file to be rejected"),
1507            Err(e) => e,
1508        };
1509        let msg = err.to_string();
1510        assert!(
1511            msg.contains("per-graph") && msg.contains("read"),
1512            "expected per-graph-in-server-file rejection, got: {msg}"
1513        );
1514    }
1515
1516    /// Positive case: a properly-shaped per-graph policy loads via
1517    /// `load_graph` and authorizes as expected. Verifies the
1518    /// kind-alignment check is permissive when the file is correct.
1519    #[test]
1520    fn load_graph_accepts_per_graph_only_policy() {
1521        let dir = tempfile::tempdir().unwrap();
1522        let path = dir.path().join("ok-graph-policy.yaml");
1523        std::fs::write(
1524            &path,
1525            r#"
1526version: 1
1527groups:
1528  team: [act-andrew]
1529rules:
1530  - id: team-read
1531    allow:
1532      actors: { group: team }
1533      actions: [read]
1534      branch_scope: any
1535"#,
1536        )
1537        .unwrap();
1538        let engine = PolicyEngine::load_graph(&path, "g1").unwrap();
1539        let decision = engine
1540            .authorize(
1541                "act-andrew",
1542                &PolicyRequest {
1543                    action: PolicyAction::Read,
1544                    branch: Some("main".to_string()),
1545                    target_branch: None,
1546                },
1547            )
1548            .unwrap();
1549        assert!(decision.allowed);
1550    }
1551
1552    /// Positive case: a properly-shaped server policy loads via
1553    /// `load_server` and authorizes the `graph_list` action.
1554    #[test]
1555    fn load_server_accepts_server_only_policy() {
1556        let dir = tempfile::tempdir().unwrap();
1557        let path = dir.path().join("ok-server-policy.yaml");
1558        std::fs::write(
1559            &path,
1560            r#"
1561version: 1
1562groups:
1563  admins: [act-andrew]
1564rules:
1565  - id: admins-list-graphs
1566    allow:
1567      actors: { group: admins }
1568      actions: [graph_list]
1569"#,
1570        )
1571        .unwrap();
1572        let engine = PolicyEngine::load_server(&path).unwrap();
1573        let decision = engine
1574            .authorize(
1575                "act-andrew",
1576                &PolicyRequest {
1577                    action: PolicyAction::GraphList,
1578                    branch: None,
1579                    target_branch: None,
1580                },
1581            )
1582            .unwrap();
1583        assert!(decision.allowed);
1584    }
1585}