Skip to main content

typesec_rbac/
graph_policy.rs

1//! Graph-aware policy definitions backed by Grust graphs.
2
3#![allow(missing_docs)]
4
5use std::collections::{BTreeMap, BTreeSet, VecDeque};
6
7use glob::Pattern;
8use grust::prelude::{
9    Direction, Field, FieldType, Graph, GraphSchema, Label, Node, NodeId, TypedEdge,
10    TypedGraphBuilder, TypedNode, Value, garde,
11    zod_rs::prelude::{object, string},
12};
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::{Map as JsonMap, Value as JsonValue};
15use tracing::debug;
16use typesec_core::policy::{PolicyEngine, PolicyResult};
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct GraphPolicyDocument {
20    pub graph_policy: GraphPolicy,
21}
22
23impl GraphPolicyDocument {
24    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
25        let value: serde_yaml::Value =
26            serde_yaml::from_str(yaml).map_err(|err| format!("YAML parse error: {err}"))?;
27        let value =
28            serde_json::to_value(value).map_err(|err| format!("YAML conversion error: {err}"))?;
29        Self::from_json_value(value)
30    }
31
32    pub fn from_json(json: &str) -> Result<Self, String> {
33        let value = serde_json::from_str(json).map_err(|err| format!("JSON parse error: {err}"))?;
34        Self::from_json_value(value)
35    }
36
37    pub fn from_json_value(value: JsonValue) -> Result<Self, String> {
38        let raw: RawGraphPolicyDocument = serde_json::from_value(value)
39            .map_err(|err| format!("Graph policy schema error: {err}"))?;
40        let graph = build_typed_graph(&raw.graph_policy.graph)?;
41        Ok(Self {
42            graph_policy: GraphPolicy {
43                graph,
44                rules: raw.graph_policy.rules,
45            },
46        })
47    }
48
49    pub fn validate(&self) -> Result<(), String> {
50        validate_graph(&self.graph_policy.graph)?;
51        if self.graph_policy.rules.is_empty() {
52            return Err("graph policy must contain at least one rule".to_string());
53        }
54        for rule in &self.graph_policy.rules {
55            Pattern::new(&rule.resource)
56                .map_err(|err| format!("invalid resource pattern '{}': {err}", rule.resource))?;
57        }
58        Ok(())
59    }
60
61    pub fn graph_schema(&self) -> GraphSchema {
62        company_graph_schema()
63    }
64}
65
66pub fn company_graph_schema() -> GraphSchema {
67    GraphSchema::builder()
68        .node("Agent", vec![Field::required("id", FieldType::String)])
69        .node("Role", vec![Field::required("id", FieldType::String)])
70        .node(
71            "Employee",
72            vec![
73                Field::required("id", FieldType::String),
74                Field::required("name", FieldType::String),
75                Field::required("title", FieldType::String),
76                Field::required("department", FieldType::String),
77                Field::required("level", FieldType::String),
78                Field::required("compensation_band", FieldType::String),
79            ],
80        )
81        .edge(
82            "HAS_ROLE",
83            vec![Label::from("Agent")],
84            vec![Label::from("Role")],
85            Vec::<Field>::new(),
86        )
87        .edge(
88            "REPORTS_TO",
89            vec![Label::from("Employee")],
90            vec![Label::from("Employee")],
91            vec![
92                Field::optional("visibility", FieldType::String),
93                Field::optional("source", FieldType::String),
94            ],
95        )
96        .build()
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct GraphPolicy {
101    #[serde(deserialize_with = "deserialize_graph")]
102    pub graph: Graph,
103    #[serde(default)]
104    pub rules: Vec<GraphRule>,
105}
106
107#[derive(Debug, Clone, Deserialize)]
108struct RawGraphPolicyDocument {
109    graph_policy: RawGraphPolicy,
110}
111
112#[derive(Debug, Clone, Deserialize)]
113struct RawGraphPolicy {
114    graph: AuthoredGraph,
115    #[serde(default)]
116    rules: Vec<GraphRule>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120struct AuthoredGraph {
121    #[serde(default)]
122    nodes: Vec<AuthoredNode>,
123    #[serde(default)]
124    edges: Vec<AuthoredEdge>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
128struct AuthoredNode {
129    id: String,
130    label: String,
131    #[serde(default)]
132    props: JsonMap<String, JsonValue>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136struct AuthoredEdge {
137    label: String,
138    from: String,
139    to: String,
140    #[serde(default)]
141    props: JsonMap<String, JsonValue>,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize, garde::Validate)]
145#[garde(allow_unvalidated)]
146struct AgentNode {
147    #[garde(length(min = 1))]
148    id: String,
149}
150
151impl TypedNode for AgentNode {
152    const LABEL: &'static str = "Agent";
153
154    fn node_id(&self) -> NodeId {
155        self.id.clone().into()
156    }
157}
158
159#[derive(Debug, Clone, Deserialize, Serialize, garde::Validate)]
160#[garde(allow_unvalidated)]
161struct RoleNode {
162    #[garde(length(min = 1))]
163    id: String,
164}
165
166impl TypedNode for RoleNode {
167    const LABEL: &'static str = "Role";
168
169    fn node_id(&self) -> NodeId {
170        self.id.clone().into()
171    }
172}
173
174#[derive(Debug, Clone, Deserialize, Serialize, garde::Validate)]
175#[garde(allow_unvalidated)]
176struct EmployeeNode {
177    #[garde(length(min = 1))]
178    id: String,
179    #[garde(length(min = 1))]
180    name: String,
181    #[garde(length(min = 1))]
182    title: String,
183    #[garde(length(min = 1))]
184    department: String,
185    #[garde(length(min = 1))]
186    level: String,
187    #[garde(length(min = 1))]
188    compensation_band: String,
189}
190
191impl TypedNode for EmployeeNode {
192    const LABEL: &'static str = "Employee";
193
194    fn node_id(&self) -> NodeId {
195        self.id.clone().into()
196    }
197}
198
199#[derive(Debug, Clone, Deserialize, Serialize, garde::Validate)]
200#[garde(allow_unvalidated)]
201struct HasRoleEdge {
202    #[garde(length(min = 1))]
203    from: String,
204    #[garde(length(min = 1))]
205    to: String,
206}
207
208impl TypedEdge for HasRoleEdge {
209    const LABEL: &'static str = "HAS_ROLE";
210
211    fn from_node_id(&self) -> NodeId {
212        self.from.clone().into()
213    }
214
215    fn to_node_id(&self) -> NodeId {
216        self.to.clone().into()
217    }
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize, garde::Validate)]
221#[garde(allow_unvalidated)]
222struct ReportsToEdge {
223    #[garde(length(min = 1))]
224    from: String,
225    #[garde(length(min = 1))]
226    to: String,
227}
228
229impl TypedEdge for ReportsToEdge {
230    const LABEL: &'static str = "REPORTS_TO";
231
232    fn from_node_id(&self) -> NodeId {
233        self.from.clone().into()
234    }
235
236    fn to_node_id(&self) -> NodeId {
237        self.to.clone().into()
238    }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct GraphRule {
243    #[serde(default = "allow_effect")]
244    pub effect: RuleEffect,
245    #[serde(default)]
246    pub subject: Option<String>,
247    #[serde(default)]
248    pub subject_has_role: Option<String>,
249    pub action: String,
250    pub resource: String,
251    #[serde(default, rename = "where")]
252    pub conditions: GraphConditions,
253}
254
255#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum RuleEffect {
258    Allow,
259    Deny,
260}
261
262fn allow_effect() -> RuleEffect {
263    RuleEffect::Allow
264}
265
266fn deserialize_graph<'de, D>(deserializer: D) -> Result<Graph, D::Error>
267where
268    D: Deserializer<'de>,
269{
270    let value = serde_yaml::Value::deserialize(deserializer)?;
271    let yaml = serde_yaml::to_string(&value).map_err(serde::de::Error::custom)?;
272    Graph::from_yaml(&yaml).map_err(serde::de::Error::custom)
273}
274
275fn build_typed_graph(graph: &AuthoredGraph) -> Result<Graph, String> {
276    let mut builder = TypedGraphBuilder::new();
277    let mut labels = BTreeMap::new();
278
279    for node in &graph.nodes {
280        if labels.insert(node.id.clone(), node.label.clone()).is_some() {
281            return Err(format!("duplicate graph node '{}'", node.id));
282        }
283
284        let value = flattened_node_value(node)?;
285        match node.label.as_str() {
286            "Agent" => {
287                let schema = object().field("id", string().min(1)).strict();
288                builder
289                    .add_node_from_json::<AgentNode, _>(&schema, &value)
290                    .map_err(|err| format!("Agent node '{}' validation failed: {err}", node.id))?;
291            }
292            "Role" => {
293                let schema = object().field("id", string().min(1)).strict();
294                builder
295                    .add_node_from_json::<RoleNode, _>(&schema, &value)
296                    .map_err(|err| format!("Role node '{}' validation failed: {err}", node.id))?;
297            }
298            "Employee" => {
299                let schema = object()
300                    .field("id", string().min(1))
301                    .field("name", string().min(1))
302                    .field("title", string().min(1))
303                    .field("department", string().min(1))
304                    .field("level", string().min(1))
305                    .field("compensation_band", string().min(1))
306                    .strict();
307                builder
308                    .add_node_from_json::<EmployeeNode, _>(&schema, &value)
309                    .map_err(|err| {
310                        format!("Employee node '{}' validation failed: {err}", node.id)
311                    })?;
312            }
313            other => return Err(format!("unknown graph node label '{other}'")),
314        }
315    }
316
317    for edge in &graph.edges {
318        if !edge.props.is_empty() {
319            return Err(format!(
320                "edge '{}' from '{}' to '{}' does not allow props",
321                edge.label, edge.from, edge.to
322            ));
323        }
324        match edge.label.as_str() {
325            "HAS_ROLE" => {
326                validate_endpoint_label(&labels, &edge.from, "Agent", &edge.label, "from")?;
327                validate_endpoint_label(&labels, &edge.to, "Role", &edge.label, "to")?;
328                let schema = object()
329                    .field("from", string().min(1))
330                    .field("to", string().min(1))
331                    .strict();
332                builder
333                    .add_edge_from_json::<HasRoleEdge, _>(&schema, &edge_value(edge))
334                    .map_err(|err| {
335                        format!(
336                            "HAS_ROLE edge '{}' -> '{}' validation failed: {err}",
337                            edge.from, edge.to
338                        )
339                    })?;
340            }
341            "REPORTS_TO" => {
342                validate_endpoint_label(&labels, &edge.from, "Employee", &edge.label, "from")?;
343                validate_endpoint_label(&labels, &edge.to, "Employee", &edge.label, "to")?;
344                let schema = object()
345                    .field("from", string().min(1))
346                    .field("to", string().min(1))
347                    .strict();
348                builder
349                    .add_edge_from_json::<ReportsToEdge, _>(&schema, &edge_value(edge))
350                    .map_err(|err| {
351                        format!(
352                            "REPORTS_TO edge '{}' -> '{}' validation failed: {err}",
353                            edge.from, edge.to
354                        )
355                    })?;
356            }
357            other => return Err(format!("unknown graph edge label '{other}'")),
358        }
359    }
360
361    Ok(builder.build())
362}
363
364fn flattened_node_value(node: &AuthoredNode) -> Result<JsonValue, String> {
365    let mut fields = JsonMap::new();
366    fields.insert("id".to_string(), JsonValue::String(node.id.clone()));
367    for (key, value) in &node.props {
368        if key == "id" {
369            return Err(format!(
370                "node '{}' must use top-level id, not props.id",
371                node.id
372            ));
373        }
374        fields.insert(key.clone(), value.clone());
375    }
376    Ok(JsonValue::Object(fields))
377}
378
379fn edge_value(edge: &AuthoredEdge) -> JsonValue {
380    let mut fields = JsonMap::new();
381    fields.insert("from".to_string(), JsonValue::String(edge.from.clone()));
382    fields.insert("to".to_string(), JsonValue::String(edge.to.clone()));
383    JsonValue::Object(fields)
384}
385
386fn validate_endpoint_label(
387    labels: &BTreeMap<String, String>,
388    id: &str,
389    expected: &str,
390    edge_label: &str,
391    endpoint: &str,
392) -> Result<(), String> {
393    let Some(actual) = labels.get(id) else {
394        return Err(format!(
395            "{edge_label} edge references unknown {endpoint} node '{id}'"
396        ));
397    };
398    if actual != expected {
399        return Err(format!(
400            "{edge_label} edge {endpoint} node '{id}' must have label '{expected}', found '{actual}'"
401        ));
402    }
403    Ok(())
404}
405
406#[derive(Debug, Clone, Default, Serialize, Deserialize)]
407pub struct GraphConditions {
408    #[serde(default)]
409    pub target: Option<TargetCondition>,
410    #[serde(default)]
411    pub relationship: Option<RelationshipCondition>,
412    #[serde(default)]
413    pub path_exists: Option<PathCondition>,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct TargetCondition {
418    pub resource_prefix: String,
419    #[serde(default)]
420    pub label: Option<String>,
421    #[serde(default)]
422    pub property_equals: BTreeMap<String, Scalar>,
423    #[serde(default)]
424    pub property_not_equals: BTreeMap<String, Scalar>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RelationshipCondition {
429    pub resource_prefix: String,
430    pub edge_label: String,
431    #[serde(default)]
432    pub from_label: Option<String>,
433    #[serde(default)]
434    pub to_label: Option<String>,
435    #[serde(default)]
436    pub no_cycle: bool,
437    #[serde(default)]
438    pub from_property_equals: BTreeMap<String, Scalar>,
439    #[serde(default)]
440    pub to_property_equals: BTreeMap<String, Scalar>,
441    #[serde(default)]
442    pub from_property_not_equals: BTreeMap<String, Scalar>,
443    #[serde(default)]
444    pub to_property_not_equals: BTreeMap<String, Scalar>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct PathCondition {
449    pub from: String,
450    pub to: String,
451    pub edge: String,
452    #[serde(default)]
453    pub direction: PathDirection,
454}
455
456#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457#[serde(rename_all = "snake_case")]
458pub enum PathDirection {
459    #[default]
460    Out,
461    In,
462    Both,
463}
464
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466#[serde(untagged)]
467pub enum Scalar {
468    Bool(bool),
469    Int(i64),
470    Float(f64),
471    String(String),
472}
473
474impl Scalar {
475    fn as_value(&self) -> Value {
476        match self {
477            Self::Bool(value) => Value::from(*value),
478            Self::Int(value) => Value::from(*value),
479            Self::Float(value) => Value::from(*value),
480            Self::String(value) => Value::from(value),
481        }
482    }
483}
484
485pub struct GraphPolicyEngine {
486    doc: GraphPolicyDocument,
487}
488
489impl GraphPolicyEngine {
490    pub fn new(doc: GraphPolicyDocument) -> Result<Self, String> {
491        doc.validate()?;
492        Ok(Self { doc })
493    }
494
495    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
496        let doc = GraphPolicyDocument::from_yaml(yaml)
497            .map_err(|err| format!("Graph policy YAML parse error: {err}"))?;
498        Self::new(doc)
499    }
500}
501
502impl PolicyEngine for GraphPolicyEngine {
503    fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
504        debug!(subject, action, resource, "graph policy check");
505
506        let graph = &self.doc.graph_policy.graph;
507        let mut allow = None;
508
509        for rule in &self.doc.graph_policy.rules {
510            if !rule_matches(graph, rule, subject, action, resource) {
511                continue;
512            }
513
514            match rule.effect {
515                RuleEffect::Deny => {
516                    return PolicyResult::Deny(format!(
517                        "graph policy deny rule matched '{action}' on '{resource}'"
518                    ));
519                }
520                RuleEffect::Allow => {
521                    allow = Some(PolicyResult::Allow);
522                }
523            }
524        }
525
526        allow.unwrap_or_else(|| {
527            PolicyResult::Deny(format!(
528                "no graph rule grants '{subject}' permission '{action}' on '{resource}'"
529            ))
530        })
531    }
532}
533
534fn rule_matches(
535    graph: &Graph,
536    rule: &GraphRule,
537    subject: &str,
538    action: &str,
539    resource: &str,
540) -> bool {
541    if rule.action != action {
542        return false;
543    }
544    if !matches_glob(&rule.resource, resource) {
545        return false;
546    }
547    if let Some(expected) = &rule.subject
548        && expected != subject
549    {
550        return false;
551    }
552    if let Some(role) = &rule.subject_has_role
553        && !has_labeled_edge(graph, subject, role, "HAS_ROLE")
554    {
555        return false;
556    }
557
558    conditions_match(graph, &rule.conditions, subject, resource)
559}
560
561fn conditions_match(
562    graph: &Graph,
563    conditions: &GraphConditions,
564    subject: &str,
565    resource: &str,
566) -> bool {
567    if let Some(target) = &conditions.target
568        && !target_matches(graph, target, resource)
569    {
570        return false;
571    }
572    if let Some(relationship) = &conditions.relationship
573        && !relationship_matches(graph, relationship, resource)
574    {
575        return false;
576    }
577    if let Some(path) = &conditions.path_exists
578        && !path_matches(graph, path, subject)
579    {
580        return false;
581    }
582    true
583}
584
585fn target_matches(graph: &Graph, condition: &TargetCondition, resource: &str) -> bool {
586    let node_id = match resource.strip_prefix(&condition.resource_prefix) {
587        Some(id) => id,
588        None => return false,
589    };
590    let node = match node_by_id(graph, node_id) {
591        Some(node) => node,
592        None => return false,
593    };
594
595    node_matches(
596        node,
597        condition.label.as_deref(),
598        &condition.property_equals,
599        &condition.property_not_equals,
600    )
601}
602
603fn relationship_matches(graph: &Graph, condition: &RelationshipCondition, resource: &str) -> bool {
604    let endpoints = match resource.strip_prefix(&condition.resource_prefix) {
605        Some(value) => value,
606        None => return false,
607    };
608    let Some((from, to)) = endpoints.split_once('/') else {
609        return false;
610    };
611
612    let from_node = match node_by_id(graph, from) {
613        Some(node) => node,
614        None => return false,
615    };
616    let to_node = match node_by_id(graph, to) {
617        Some(node) => node,
618        None => return false,
619    };
620
621    if !node_matches(
622        from_node,
623        condition.from_label.as_deref(),
624        &condition.from_property_equals,
625        &condition.from_property_not_equals,
626    ) {
627        return false;
628    }
629    if !node_matches(
630        to_node,
631        condition.to_label.as_deref(),
632        &condition.to_property_equals,
633        &condition.to_property_not_equals,
634    ) {
635        return false;
636    }
637    if condition.no_cycle && path_exists(graph, to, from, &condition.edge_label, Direction::Out) {
638        return false;
639    }
640
641    true
642}
643
644fn path_matches(graph: &Graph, condition: &PathCondition, subject: &str) -> bool {
645    let from = expand_subject(&condition.from, subject);
646    let to = expand_subject(&condition.to, subject);
647    path_exists(
648        graph,
649        &from,
650        &to,
651        &condition.edge,
652        match condition.direction {
653            PathDirection::Out => Direction::Out,
654            PathDirection::In => Direction::In,
655            PathDirection::Both => Direction::Both,
656        },
657    )
658}
659
660fn node_matches(
661    node: &Node,
662    label: Option<&str>,
663    property_equals: &BTreeMap<String, Scalar>,
664    property_not_equals: &BTreeMap<String, Scalar>,
665) -> bool {
666    if let Some(label) = label
667        && node.label != Label::from(label)
668    {
669        return false;
670    }
671    for (key, value) in property_equals {
672        if node.props.get(key) != Some(&value.as_value()) {
673            return false;
674        }
675    }
676    for (key, value) in property_not_equals {
677        if node.props.get(key) == Some(&value.as_value()) {
678            return false;
679        }
680    }
681    true
682}
683
684fn validate_graph(graph: &Graph) -> Result<(), String> {
685    let node_ids = graph
686        .nodes
687        .iter()
688        .map(|node| node.id.clone())
689        .collect::<BTreeSet<_>>();
690    for edge in &graph.edges {
691        if !node_ids.contains(&edge.from) {
692            return Err(format!(
693                "edge '{}' references unknown from node '{}'",
694                edge.label, edge.from
695            ));
696        }
697        if !node_ids.contains(&edge.to) {
698            return Err(format!(
699                "edge '{}' references unknown to node '{}'",
700                edge.label, edge.to
701            ));
702        }
703    }
704    Ok(())
705}
706
707fn node_by_id<'a>(graph: &'a Graph, id: &str) -> Option<&'a Node> {
708    let id = NodeId::from(id);
709    graph.nodes.iter().find(|node| node.id == id)
710}
711
712fn has_labeled_edge(graph: &Graph, from: &str, to: &str, label: &str) -> bool {
713    graph.edges.iter().any(|edge| {
714        edge.from == NodeId::from(from)
715            && edge.to == NodeId::from(to)
716            && edge.label == Label::from(label)
717    })
718}
719
720fn path_exists(
721    graph: &Graph,
722    from: &str,
723    to: &str,
724    edge_label: &str,
725    direction: Direction,
726) -> bool {
727    let start = NodeId::from(from);
728    let goal = NodeId::from(to);
729    if start == goal {
730        return true;
731    }
732
733    let mut seen = BTreeSet::new();
734    let mut queue = VecDeque::from([start.clone()]);
735    seen.insert(start);
736
737    while let Some(current) = queue.pop_front() {
738        for next in neighbors(graph, &current, edge_label, &direction) {
739            if next == goal {
740                return true;
741            }
742            if seen.insert(next.clone()) {
743                queue.push_back(next);
744            }
745        }
746    }
747
748    false
749}
750
751fn neighbors(graph: &Graph, node: &NodeId, edge_label: &str, direction: &Direction) -> Vec<NodeId> {
752    graph
753        .edges
754        .iter()
755        .filter(|edge| edge.label == Label::from(edge_label))
756        .filter_map(|edge| match direction {
757            Direction::Out if edge.from == *node => Some(edge.to.clone()),
758            Direction::In if edge.to == *node => Some(edge.from.clone()),
759            Direction::Both if edge.from == *node => Some(edge.to.clone()),
760            Direction::Both if edge.to == *node => Some(edge.from.clone()),
761            _ => None,
762        })
763        .collect()
764}
765
766fn expand_subject(value: &str, subject: &str) -> String {
767    if value == "$subject" {
768        subject.to_string()
769    } else {
770        value.to_string()
771    }
772}
773
774fn matches_glob(pattern: &str, resource: &str) -> bool {
775    pattern == "*" || Pattern::new(pattern).is_ok_and(|p| p.matches(resource))
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    const YAML: &str = r#"
783graph_policy:
784  graph:
785    nodes:
786      - id: agent:hr-onboarding
787        label: Agent
788      - id: agent:employee-nia
789        label: Agent
790      - id: role:hr_graph_writer
791        label: Role
792      - id: employee:evelyn
793        label: Employee
794        props:
795          name: Evelyn Chen
796          title: Chief Executive Officer
797          department: Executive
798          level: Executive
799          compensation_band: exec-1
800      - id: employee:marco
801        label: Employee
802        props:
803          name: Marco Silva
804          title: Engineering Manager
805          department: Engineering
806          level: M2
807          compensation_band: m2-3
808      - id: employee:nia
809        label: Employee
810        props:
811          name: Nia Patel
812          title: Senior Software Engineer
813          department: Engineering
814          level: IC4
815          compensation_band: ic4-4
816    edges:
817      - label: HAS_ROLE
818        from: agent:hr-onboarding
819        to: role:hr_graph_writer
820      - label: REPORTS_TO
821        from: employee:marco
822        to: employee:evelyn
823      - label: REPORTS_TO
824        from: employee:nia
825        to: employee:marco
826  rules:
827    - subject_has_role: role:hr_graph_writer
828      action: write
829      resource: employee/private/*
830      where:
831        target:
832          resource_prefix: employee/private/
833          label: Employee
834          property_not_equals:
835            level: Executive
836    - subject_has_role: role:hr_graph_writer
837      action: write
838      resource: relationship/reports_to/*/*
839      where:
840        relationship:
841          resource_prefix: relationship/reports_to/
842          edge_label: REPORTS_TO
843          from_label: Employee
844          to_label: Employee
845          no_cycle: true
846"#;
847
848    fn engine() -> GraphPolicyEngine {
849        GraphPolicyEngine::from_yaml(YAML).expect("graph policy should load")
850    }
851
852    #[test]
853    fn role_can_write_non_executive_employee_node() {
854        assert_eq!(
855            engine().check(
856                "agent:hr-onboarding",
857                "write",
858                "employee/private/employee:nia"
859            ),
860            PolicyResult::Allow
861        );
862    }
863
864    #[test]
865    fn role_cannot_write_executive_employee_node() {
866        assert!(matches!(
867            engine().check(
868                "agent:hr-onboarding",
869                "write",
870                "employee/private/employee:evelyn"
871            ),
872            PolicyResult::Deny(_)
873        ));
874    }
875
876    #[test]
877    fn unknown_role_assignment_is_denied() {
878        assert!(matches!(
879            engine().check(
880                "agent:employee-nia",
881                "write",
882                "employee/private/employee:nia"
883            ),
884            PolicyResult::Deny(_)
885        ));
886    }
887
888    #[test]
889    fn relationship_write_rejects_cycles() {
890        assert!(matches!(
891            engine().check(
892                "agent:hr-onboarding",
893                "write",
894                "relationship/reports_to/employee:evelyn/employee:nia"
895            ),
896            PolicyResult::Deny(_)
897        ));
898    }
899
900    #[test]
901    fn relationship_write_allows_tree_extension() {
902        assert_eq!(
903            engine().check(
904                "agent:hr-onboarding",
905                "write",
906                "relationship/reports_to/employee:nia/employee:evelyn"
907            ),
908            PolicyResult::Allow
909        );
910    }
911
912    #[test]
913    fn graph_policy_loads_from_json() {
914        let json = r#"
915{
916  "graph_policy": {
917    "graph": {
918      "nodes": [
919        { "id": "agent:hr-onboarding", "label": "Agent" },
920        { "id": "role:hr_graph_writer", "label": "Role" },
921        {
922          "id": "employee:nia",
923          "label": "Employee",
924          "props": {
925            "name": "Nia Patel",
926            "title": "Senior Software Engineer",
927            "department": "Engineering",
928            "level": "IC4",
929            "compensation_band": "ic4-4"
930          }
931        }
932      ],
933      "edges": [
934        {
935          "label": "HAS_ROLE",
936          "from": "agent:hr-onboarding",
937          "to": "role:hr_graph_writer"
938        }
939      ]
940    },
941    "rules": [
942      {
943        "subject_has_role": "role:hr_graph_writer",
944        "action": "write",
945        "resource": "employee/private/*",
946        "where": {
947          "target": {
948            "resource_prefix": "employee/private/",
949            "label": "Employee",
950            "property_equals": { "level": "IC4" }
951          }
952        }
953      }
954    ]
955  }
956}
957"#;
958        let doc = GraphPolicyDocument::from_json(json).expect("JSON graph policy should load");
959        assert_eq!(doc.graph_policy.graph.nodes.len(), 3);
960        assert_eq!(doc.graph_policy.graph.edges.len(), 1);
961    }
962
963    #[test]
964    fn exported_company_graph_schema_validates_policy_graph() {
965        let doc = GraphPolicyDocument::from_yaml(YAML).expect("graph policy should load");
966        let schema = doc.graph_schema();
967        schema
968            .validate_graph(&doc.graph_policy.graph)
969            .expect("schema should validate graph policy graph");
970    }
971
972    #[test]
973    fn graph_policy_rejects_unknown_node_label() {
974        let yaml = YAML.replace("label: Role", "label: Group");
975        let err = GraphPolicyDocument::from_yaml(&yaml).expect_err("unknown label should fail");
976        assert!(err.contains("unknown graph node label 'Group'"));
977    }
978
979    #[test]
980    fn graph_policy_rejects_extra_employee_property() {
981        let yaml = YAML.replace(
982            "compensation_band: ic4-4",
983            "compensation_band: ic4-4\n          clearance: confidential",
984        );
985        let err = GraphPolicyDocument::from_yaml(&yaml).expect_err("strict schema should fail");
986        assert!(err.contains("Employee node 'employee:nia' validation failed"));
987    }
988
989    #[test]
990    fn graph_policy_rejects_invalid_edge_endpoint_types() {
991        let yaml = YAML.replace(
992            "from: agent:hr-onboarding\n        to: role:hr_graph_writer",
993            "from: employee:nia\n        to: role:hr_graph_writer",
994        );
995        let err = GraphPolicyDocument::from_yaml(&yaml).expect_err("endpoint type should fail");
996        assert!(err.contains("HAS_ROLE edge from node 'employee:nia' must have label 'Agent'"));
997    }
998}