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::{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, &current, 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}