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