1#![allow(missing_docs)]
4
5use std::collections::{BTreeMap, BTreeSet, VecDeque};
6
7use glob::Pattern;
8use grust::prelude::{Direction, Graph, Label, Node, NodeId, Value};
9use serde::{Deserialize, Deserializer, Serialize};
10use tracing::debug;
11use typesec_core::policy::{PolicyEngine, PolicyResult};
12
13#[derive(Debug, Clone, Deserialize)]
14pub struct GraphPolicyDocument {
15 pub graph_policy: GraphPolicy,
16}
17
18impl GraphPolicyDocument {
19 pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
20 serde_yaml::from_str(yaml)
21 }
22
23 pub fn validate(&self) -> Result<(), String> {
24 validate_graph(&self.graph_policy.graph)?;
25 if self.graph_policy.rules.is_empty() {
26 return Err("graph policy must contain at least one rule".to_string());
27 }
28 for rule in &self.graph_policy.rules {
29 Pattern::new(&rule.resource)
30 .map_err(|err| format!("invalid resource pattern '{}': {err}", rule.resource))?;
31 }
32 Ok(())
33 }
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct GraphPolicy {
38 #[serde(deserialize_with = "deserialize_graph")]
39 pub graph: Graph,
40 #[serde(default)]
41 pub rules: Vec<GraphRule>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GraphRule {
46 #[serde(default = "allow_effect")]
47 pub effect: RuleEffect,
48 #[serde(default)]
49 pub subject: Option<String>,
50 #[serde(default)]
51 pub subject_has_role: Option<String>,
52 pub action: String,
53 pub resource: String,
54 #[serde(default, rename = "where")]
55 pub conditions: GraphConditions,
56}
57
58#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum RuleEffect {
61 Allow,
62 Deny,
63}
64
65fn allow_effect() -> RuleEffect {
66 RuleEffect::Allow
67}
68
69fn deserialize_graph<'de, D>(deserializer: D) -> Result<Graph, D::Error>
70where
71 D: Deserializer<'de>,
72{
73 let value = serde_yaml::Value::deserialize(deserializer)?;
74 let yaml = serde_yaml::to_string(&value).map_err(serde::de::Error::custom)?;
75 Graph::from_yaml(&yaml).map_err(serde::de::Error::custom)
76}
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct GraphConditions {
80 #[serde(default)]
81 pub target: Option<TargetCondition>,
82 #[serde(default)]
83 pub relationship: Option<RelationshipCondition>,
84 #[serde(default)]
85 pub path_exists: Option<PathCondition>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct TargetCondition {
90 pub resource_prefix: String,
91 #[serde(default)]
92 pub label: Option<String>,
93 #[serde(default)]
94 pub property_equals: BTreeMap<String, Scalar>,
95 #[serde(default)]
96 pub property_not_equals: BTreeMap<String, Scalar>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct RelationshipCondition {
101 pub resource_prefix: String,
102 pub edge_label: String,
103 #[serde(default)]
104 pub from_label: Option<String>,
105 #[serde(default)]
106 pub to_label: Option<String>,
107 #[serde(default)]
108 pub no_cycle: bool,
109 #[serde(default)]
110 pub from_property_equals: BTreeMap<String, Scalar>,
111 #[serde(default)]
112 pub to_property_equals: BTreeMap<String, Scalar>,
113 #[serde(default)]
114 pub from_property_not_equals: BTreeMap<String, Scalar>,
115 #[serde(default)]
116 pub to_property_not_equals: BTreeMap<String, Scalar>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct PathCondition {
121 pub from: String,
122 pub to: String,
123 pub edge: String,
124 #[serde(default)]
125 pub direction: PathDirection,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum PathDirection {
131 #[default]
132 Out,
133 In,
134 Both,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum Scalar {
140 Bool(bool),
141 Int(i64),
142 Float(f64),
143 String(String),
144}
145
146impl Scalar {
147 fn as_value(&self) -> Value {
148 match self {
149 Self::Bool(value) => Value::from(*value),
150 Self::Int(value) => Value::from(*value),
151 Self::Float(value) => Value::from(*value),
152 Self::String(value) => Value::from(value),
153 }
154 }
155}
156
157pub struct GraphPolicyEngine {
158 doc: GraphPolicyDocument,
159}
160
161impl GraphPolicyEngine {
162 pub fn new(doc: GraphPolicyDocument) -> Result<Self, String> {
163 doc.validate()?;
164 Ok(Self { doc })
165 }
166
167 pub fn from_yaml(yaml: &str) -> Result<Self, String> {
168 let doc = GraphPolicyDocument::from_yaml(yaml)
169 .map_err(|err| format!("Graph policy YAML parse error: {err}"))?;
170 Self::new(doc)
171 }
172}
173
174impl PolicyEngine for GraphPolicyEngine {
175 fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
176 debug!(subject, action, resource, "graph policy check");
177
178 let graph = &self.doc.graph_policy.graph;
179 let mut allow = None;
180
181 for rule in &self.doc.graph_policy.rules {
182 if !rule_matches(graph, rule, subject, action, resource) {
183 continue;
184 }
185
186 match rule.effect {
187 RuleEffect::Deny => {
188 return PolicyResult::Deny(format!(
189 "graph policy deny rule matched '{action}' on '{resource}'"
190 ));
191 }
192 RuleEffect::Allow => {
193 allow = Some(PolicyResult::Allow);
194 }
195 }
196 }
197
198 allow.unwrap_or_else(|| {
199 PolicyResult::Deny(format!(
200 "no graph rule grants '{subject}' permission '{action}' on '{resource}'"
201 ))
202 })
203 }
204}
205
206fn rule_matches(
207 graph: &Graph,
208 rule: &GraphRule,
209 subject: &str,
210 action: &str,
211 resource: &str,
212) -> bool {
213 if rule.action != action {
214 return false;
215 }
216 if !matches_glob(&rule.resource, resource) {
217 return false;
218 }
219 if let Some(expected) = &rule.subject
220 && expected != subject
221 {
222 return false;
223 }
224 if let Some(role) = &rule.subject_has_role
225 && !has_labeled_edge(graph, subject, role, "HAS_ROLE")
226 {
227 return false;
228 }
229
230 conditions_match(graph, &rule.conditions, subject, resource)
231}
232
233fn conditions_match(
234 graph: &Graph,
235 conditions: &GraphConditions,
236 subject: &str,
237 resource: &str,
238) -> bool {
239 if let Some(target) = &conditions.target
240 && !target_matches(graph, target, resource)
241 {
242 return false;
243 }
244 if let Some(relationship) = &conditions.relationship
245 && !relationship_matches(graph, relationship, resource)
246 {
247 return false;
248 }
249 if let Some(path) = &conditions.path_exists
250 && !path_matches(graph, path, subject)
251 {
252 return false;
253 }
254 true
255}
256
257fn target_matches(graph: &Graph, condition: &TargetCondition, resource: &str) -> bool {
258 let node_id = match resource.strip_prefix(&condition.resource_prefix) {
259 Some(id) => id,
260 None => return false,
261 };
262 let node = match node_by_id(graph, node_id) {
263 Some(node) => node,
264 None => return false,
265 };
266
267 node_matches(
268 node,
269 condition.label.as_deref(),
270 &condition.property_equals,
271 &condition.property_not_equals,
272 )
273}
274
275fn relationship_matches(graph: &Graph, condition: &RelationshipCondition, resource: &str) -> bool {
276 let endpoints = match resource.strip_prefix(&condition.resource_prefix) {
277 Some(value) => value,
278 None => return false,
279 };
280 let Some((from, to)) = endpoints.split_once('/') else {
281 return false;
282 };
283
284 let from_node = match node_by_id(graph, from) {
285 Some(node) => node,
286 None => return false,
287 };
288 let to_node = match node_by_id(graph, to) {
289 Some(node) => node,
290 None => return false,
291 };
292
293 if !node_matches(
294 from_node,
295 condition.from_label.as_deref(),
296 &condition.from_property_equals,
297 &condition.from_property_not_equals,
298 ) {
299 return false;
300 }
301 if !node_matches(
302 to_node,
303 condition.to_label.as_deref(),
304 &condition.to_property_equals,
305 &condition.to_property_not_equals,
306 ) {
307 return false;
308 }
309 if condition.no_cycle && path_exists(graph, to, from, &condition.edge_label, Direction::Out) {
310 return false;
311 }
312
313 true
314}
315
316fn path_matches(graph: &Graph, condition: &PathCondition, subject: &str) -> bool {
317 let from = expand_subject(&condition.from, subject);
318 let to = expand_subject(&condition.to, subject);
319 path_exists(
320 graph,
321 &from,
322 &to,
323 &condition.edge,
324 match condition.direction {
325 PathDirection::Out => Direction::Out,
326 PathDirection::In => Direction::In,
327 PathDirection::Both => Direction::Both,
328 },
329 )
330}
331
332fn node_matches(
333 node: &Node,
334 label: Option<&str>,
335 property_equals: &BTreeMap<String, Scalar>,
336 property_not_equals: &BTreeMap<String, Scalar>,
337) -> bool {
338 if let Some(label) = label
339 && node.label != Label::from(label)
340 {
341 return false;
342 }
343 for (key, value) in property_equals {
344 if node.props.get(key) != Some(&value.as_value()) {
345 return false;
346 }
347 }
348 for (key, value) in property_not_equals {
349 if node.props.get(key) == Some(&value.as_value()) {
350 return false;
351 }
352 }
353 true
354}
355
356fn validate_graph(graph: &Graph) -> Result<(), String> {
357 let node_ids = graph
358 .nodes
359 .iter()
360 .map(|node| node.id.clone())
361 .collect::<BTreeSet<_>>();
362 for edge in &graph.edges {
363 if !node_ids.contains(&edge.from) {
364 return Err(format!(
365 "edge '{}' references unknown from node '{}'",
366 edge.label, edge.from
367 ));
368 }
369 if !node_ids.contains(&edge.to) {
370 return Err(format!(
371 "edge '{}' references unknown to node '{}'",
372 edge.label, edge.to
373 ));
374 }
375 }
376 Ok(())
377}
378
379fn node_by_id<'a>(graph: &'a Graph, id: &str) -> Option<&'a Node> {
380 let id = NodeId::from(id);
381 graph.nodes.iter().find(|node| node.id == id)
382}
383
384fn has_labeled_edge(graph: &Graph, from: &str, to: &str, label: &str) -> bool {
385 graph.edges.iter().any(|edge| {
386 edge.from == NodeId::from(from)
387 && edge.to == NodeId::from(to)
388 && edge.label == Label::from(label)
389 })
390}
391
392fn path_exists(
393 graph: &Graph,
394 from: &str,
395 to: &str,
396 edge_label: &str,
397 direction: Direction,
398) -> bool {
399 let start = NodeId::from(from);
400 let goal = NodeId::from(to);
401 if start == goal {
402 return true;
403 }
404
405 let mut seen = BTreeSet::new();
406 let mut queue = VecDeque::from([start.clone()]);
407 seen.insert(start);
408
409 while let Some(current) = queue.pop_front() {
410 for next in neighbors(graph, ¤t, edge_label, &direction) {
411 if next == goal {
412 return true;
413 }
414 if seen.insert(next.clone()) {
415 queue.push_back(next);
416 }
417 }
418 }
419
420 false
421}
422
423fn neighbors(graph: &Graph, node: &NodeId, edge_label: &str, direction: &Direction) -> Vec<NodeId> {
424 graph
425 .edges
426 .iter()
427 .filter(|edge| edge.label == Label::from(edge_label))
428 .filter_map(|edge| match direction {
429 Direction::Out if edge.from == *node => Some(edge.to.clone()),
430 Direction::In if edge.to == *node => Some(edge.from.clone()),
431 Direction::Both if edge.from == *node => Some(edge.to.clone()),
432 Direction::Both if edge.to == *node => Some(edge.from.clone()),
433 _ => None,
434 })
435 .collect()
436}
437
438fn expand_subject(value: &str, subject: &str) -> String {
439 if value == "$subject" {
440 subject.to_string()
441 } else {
442 value.to_string()
443 }
444}
445
446fn matches_glob(pattern: &str, resource: &str) -> bool {
447 pattern == "*" || Pattern::new(pattern).is_ok_and(|p| p.matches(resource))
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 const YAML: &str = r#"
455graph_policy:
456 graph:
457 nodes:
458 - id: agent:hr-onboarding
459 label: Agent
460 - id: agent:employee-nia
461 label: Agent
462 - id: role:hr_graph_writer
463 label: Role
464 - id: employee:evelyn
465 label: Employee
466 props:
467 level: Executive
468 - id: employee:marco
469 label: Employee
470 props:
471 level: M2
472 - id: employee:nia
473 label: Employee
474 props:
475 level: IC4
476 edges:
477 - label: HAS_ROLE
478 from: agent:hr-onboarding
479 to: role:hr_graph_writer
480 - label: REPORTS_TO
481 from: employee:marco
482 to: employee:evelyn
483 - label: REPORTS_TO
484 from: employee:nia
485 to: employee:marco
486 rules:
487 - subject_has_role: role:hr_graph_writer
488 action: write
489 resource: employee/private/*
490 where:
491 target:
492 resource_prefix: employee/private/
493 label: Employee
494 property_not_equals:
495 level: Executive
496 - subject_has_role: role:hr_graph_writer
497 action: write
498 resource: relationship/reports_to/*/*
499 where:
500 relationship:
501 resource_prefix: relationship/reports_to/
502 edge_label: REPORTS_TO
503 from_label: Employee
504 to_label: Employee
505 no_cycle: true
506"#;
507
508 fn engine() -> GraphPolicyEngine {
509 GraphPolicyEngine::from_yaml(YAML).expect("graph policy should load")
510 }
511
512 #[test]
513 fn role_can_write_non_executive_employee_node() {
514 assert_eq!(
515 engine().check(
516 "agent:hr-onboarding",
517 "write",
518 "employee/private/employee:nia"
519 ),
520 PolicyResult::Allow
521 );
522 }
523
524 #[test]
525 fn role_cannot_write_executive_employee_node() {
526 assert!(matches!(
527 engine().check(
528 "agent:hr-onboarding",
529 "write",
530 "employee/private/employee:evelyn"
531 ),
532 PolicyResult::Deny(_)
533 ));
534 }
535
536 #[test]
537 fn unknown_role_assignment_is_denied() {
538 assert!(matches!(
539 engine().check(
540 "agent:employee-nia",
541 "write",
542 "employee/private/employee:nia"
543 ),
544 PolicyResult::Deny(_)
545 ));
546 }
547
548 #[test]
549 fn relationship_write_rejects_cycles() {
550 assert!(matches!(
551 engine().check(
552 "agent:hr-onboarding",
553 "write",
554 "relationship/reports_to/employee:evelyn/employee:nia"
555 ),
556 PolicyResult::Deny(_)
557 ));
558 }
559
560 #[test]
561 fn relationship_write_allows_tree_extension() {
562 assert_eq!(
563 engine().check(
564 "agent:hr-onboarding",
565 "write",
566 "relationship/reports_to/employee:nia/employee:evelyn"
567 ),
568 PolicyResult::Allow
569 );
570 }
571}