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