Skip to main content

typesec_rbac/graph_policy/
engine.rs

1//! The graph-policy document, engine, and [`PolicyEngine`] implementation.
2
3use grust::prelude::{Graph, GraphSchema};
4use serde::Deserialize;
5use serde_json::Value as JsonValue;
6use tracing::debug;
7use typesec_core::{
8    ResourceId, SubjectId,
9    glob::GlobPattern,
10    policy::{PolicyEngine, PolicyResult},
11};
12
13use super::authored::RawGraphPolicyDocument;
14use super::eval::{rule_matches, validate_graph};
15use super::rule::{GraphRule, RuleEffect, deserialize_graph};
16use super::schema::company_graph_schema;
17use super::typed_graph::build_typed_graph;
18
19/// A parsed graph-policy document: the typed graph plus its authored rules.
20#[derive(Debug, Clone, Deserialize)]
21pub struct GraphPolicyDocument {
22    /// The `graph_policy` block (graph + rules).
23    pub graph_policy: GraphPolicy,
24}
25
26impl GraphPolicyDocument {
27    /// Parse a document from YAML, building and type-checking the graph.
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    /// Parse a document from a JSON string.
37    pub fn from_json(json: &str) -> Result<Self, String> {
38        let value = serde_json::from_str(json).map_err(|err| format!("JSON parse error: {err}"))?;
39        Self::from_json_value(value)
40    }
41
42    /// Build a document from an already-parsed JSON value.
43    pub fn from_json_value(value: JsonValue) -> Result<Self, String> {
44        let raw: RawGraphPolicyDocument = serde_json::from_value(value)
45            .map_err(|err| format!("Graph policy schema error: {err}"))?;
46        let graph = build_typed_graph(&raw.graph_policy.graph)?;
47        Ok(Self {
48            graph_policy: GraphPolicy {
49                graph,
50                rules: raw.graph_policy.rules,
51            },
52        })
53    }
54
55    /// Validate the graph and rules: graph integrity, the company-schema
56    /// cross-check, at least one rule, and well-formed resource globs.
57    pub fn validate(&self) -> Result<(), String> {
58        validate_graph(&self.graph_policy.graph)?;
59        // Cross-check the built graph against the declarative company schema (the
60        // same schema that drives the Cypher DDL constraints) using grust's
61        // graph-type validation. This ties the schema to the load path so the two
62        // can't silently drift, and adds declared-property typing, edge
63        // endpoint-label, and uniqueness checks on top of the typed-graph build.
64        company_graph_schema()
65            .validate_graph(&self.graph_policy.graph)
66            .map_err(|err| err.to_string())?;
67        if self.graph_policy.rules.is_empty() {
68            return Err("graph policy must contain at least one rule".to_string());
69        }
70        for rule in &self.graph_policy.rules {
71            GlobPattern::compile(&rule.resource, "resource")?;
72        }
73        Ok(())
74    }
75
76    /// The declarative company [`GraphSchema`] this document is validated against.
77    pub fn graph_schema(&self) -> GraphSchema {
78        company_graph_schema()
79    }
80}
81
82/// The `graph_policy` block: a typed graph and the rules evaluated over it.
83#[derive(Debug, Clone, Deserialize)]
84pub struct GraphPolicy {
85    /// The typed Grust graph (people, roles, relationships).
86    #[serde(deserialize_with = "deserialize_graph")]
87    pub graph: Graph,
88    /// Authored rules, evaluated with deny-overrides semantics.
89    #[serde(default)]
90    pub rules: Vec<GraphRule>,
91}
92
93/// A graph-policy engine: a validated document plus the [`PolicyEngine`] impl.
94pub struct GraphPolicyEngine {
95    doc: GraphPolicyDocument,
96}
97
98impl GraphPolicyEngine {
99    /// Build an engine from a document, validating it first.
100    pub fn new(doc: GraphPolicyDocument) -> Result<Self, String> {
101        doc.validate()?;
102        Ok(Self { doc })
103    }
104
105    /// Parse and validate an engine from YAML.
106    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
107        let doc = GraphPolicyDocument::from_yaml(yaml)
108            .map_err(|err| format!("Graph policy YAML parse error: {err}"))?;
109        Self::new(doc)
110    }
111}
112
113impl PolicyEngine for GraphPolicyEngine {
114    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
115        let subject = subject.as_str();
116        let resource = resource.as_str();
117        debug!(subject, action, resource, "graph policy check");
118
119        let graph = &self.doc.graph_policy.graph;
120        let mut allow = None;
121
122        for rule in &self.doc.graph_policy.rules {
123            if !rule_matches(graph, rule, subject, action, resource) {
124                continue;
125            }
126
127            match rule.effect {
128                RuleEffect::Deny => {
129                    return PolicyResult::Deny(format!(
130                        "graph policy deny rule matched '{action}' on '{resource}'"
131                    ));
132                }
133                RuleEffect::Allow => {
134                    allow = Some(PolicyResult::Allow);
135                }
136            }
137        }
138
139        allow.unwrap_or_else(|| {
140            PolicyResult::Deny(format!(
141                "no graph rule grants '{subject}' permission '{action}' on '{resource}'"
142            ))
143        })
144    }
145}