Skip to main content

typesec_rbac/
model.rs

1//! Serde data model for RBAC YAML policies.
2
3use serde::{Deserialize, Serialize};
4
5/// The root of an RBAC policy file.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RbacPolicy {
8    /// Role definitions.
9    pub roles: Vec<RoleDefinition>,
10    /// Subject → role assignments.
11    #[serde(default)]
12    pub assignments: Vec<Assignment>,
13}
14
15/// A role with its permission set and resource patterns.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RoleDefinition {
18    /// The role's canonical name.
19    pub name: String,
20    /// Permissions granted by this role (must match `Permission::name()` values).
21    #[serde(default)]
22    pub permissions: Vec<String>,
23    /// Glob patterns for resources this role applies to.
24    #[serde(default)]
25    pub resources: Vec<String>,
26    /// Roles whose permissions this role inherits.
27    #[serde(default)]
28    pub inherits: Vec<String>,
29}
30
31/// Maps a subject to one or more roles.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Assignment {
34    /// The subject identifier (e.g., `"agent:data-pipeline"`).
35    pub subject: String,
36    /// The roles assigned to this subject.
37    pub roles: Vec<String>,
38}
39
40impl RbacPolicy {
41    /// Parse an RBAC policy from a YAML string.
42    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
43        serde_yaml::from_str(yaml)
44    }
45
46    /// Validate the policy: check that `inherits` references valid role names,
47    /// and that there are no circular inheritance chains.
48    pub fn validate(&self) -> Result<(), String> {
49        let names: std::collections::HashSet<&str> =
50            self.roles.iter().map(|r| r.name.as_str()).collect();
51
52        for role in &self.roles {
53            for parent in &role.inherits {
54                if !names.contains(parent.as_str()) {
55                    return Err(format!(
56                        "role '{}' inherits from unknown role '{}'",
57                        role.name, parent
58                    ));
59                }
60            }
61        }
62
63        // Detect cycles via DFS. `walk_inheritance` surfaces a circular chain as
64        // an error; the visitor is a no-op because we only care about the walk
65        // completing without a cycle.
66        for role in &self.roles {
67            walk_inheritance(&role.name, self, &mut |_| {})?;
68        }
69
70        Ok(())
71    }
72}
73
74/// Depth-first walk over a role's inheritance closure.
75///
76/// Starting at `start`, this visits the role itself and then each role it
77/// transitively `inherits`, in declaration order, calling `visit` exactly once
78/// per reachable role. Roles are found by linear scan; an `inherits` entry that
79/// names no known role is silently skipped (unknown-parent validation lives in
80/// [`RbacPolicy::validate`]).
81///
82/// A circular inheritance chain is reported as
83/// `"circular inheritance detected for role '<name>'"`, naming the role at which
84/// the back-edge closes the cycle. Callers that run after
85/// [`RbacPolicy::validate`] has already rejected cycles can ignore the result.
86pub(crate) fn walk_inheritance(
87    start: &str,
88    policy: &RbacPolicy,
89    visit: &mut impl FnMut(&RoleDefinition),
90) -> Result<(), String> {
91    let mut visited = std::collections::HashSet::new();
92    let mut path = std::collections::HashSet::new();
93    walk_inheritance_inner(start, policy, &mut visited, &mut path, visit)
94}
95
96fn walk_inheritance_inner(
97    role_name: &str,
98    policy: &RbacPolicy,
99    visited: &mut std::collections::HashSet<String>,
100    path: &mut std::collections::HashSet<String>,
101    visit: &mut impl FnMut(&RoleDefinition),
102) -> Result<(), String> {
103    if path.contains(role_name) {
104        return Err(format!(
105            "circular inheritance detected for role '{role_name}'"
106        ));
107    }
108    if !visited.insert(role_name.to_owned()) {
109        return Ok(());
110    }
111
112    let Some(role) = policy.roles.iter().find(|r| r.name == role_name) else {
113        return Ok(());
114    };
115
116    path.insert(role_name.to_owned());
117    visit(role);
118    for parent in &role.inherits {
119        walk_inheritance_inner(parent, policy, visited, path, visit)?;
120    }
121    path.remove(role_name);
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests;