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