1#![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, ¤t, 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}