Skip to main content

typesec_rbac/
codegen.rs

1//! Code generator: RBAC YAML → Rust source.
2//!
3//! `typesec generate --policy rbac.yaml --out src/policy_gen.rs` calls
4//! [`generate_rust`] and writes the result to a file.
5//!
6//! ## What gets generated?
7//!
8//! For each role in the YAML, the generator emits:
9//!
10//! ```rust,ignore
11//! // Auto-generated by typesec-cli — DO NOT EDIT
12//! use typesec_core::role::Role;
13//!
14//! pub struct Analyst;
15//! impl Role for Analyst {
16//!     fn name() -> &'static str { "analyst" }
17//!     fn permission_names() -> &'static [&'static str] { &["read", "read_sensitive"] }
18//!     fn resource_patterns() -> &'static [&'static str] { &["reports/*", "metrics/*"] }
19//! }
20//! ```
21//!
22//! These typed role structs let the compiler verify that the roles you reference
23//! in application code actually exist in the policy file. If you rename a role in
24//! YAML and regenerate, any code referencing the old name will fail to compile.
25
26use crate::model::RbacPolicy;
27
28/// Generate Rust source code for the roles defined in `policy`.
29///
30/// The output is a complete `.rs` file ready to be `include!`'d or written to disk.
31pub fn generate_rust(policy: &RbacPolicy) -> String {
32    let mut out = String::new();
33
34    out.push_str("// Auto-generated by typesec-cli — DO NOT EDIT\n");
35    out.push_str("// Source: RBAC policy file\n");
36    out.push_str("//\n");
37    out.push_str("// Each struct below corresponds to a role in the policy YAML.\n");
38    out.push_str("// Changing a role name in YAML and regenerating will cause compile\n");
39    out.push_str("// errors in any code that references the old struct name — which is\n");
40    out.push_str("// the point: the compiler enforces policy consistency.\n\n");
41    out.push_str("#[allow(dead_code, non_camel_case_types)]\n");
42
43    for role in &policy.roles {
44        let struct_name = to_pascal_case(&role.name);
45
46        out.push_str(&format!("/// Role `{}`.\n", role.name));
47        if !role.permissions.is_empty() {
48            out.push_str(&format!(
49                "/// Permissions: {}.\n",
50                role.permissions.join(", ")
51            ));
52        }
53        if !role.resources.is_empty() {
54            out.push_str(&format!("/// Resources: {}.\n", role.resources.join(", ")));
55        }
56
57        out.push_str("#[derive(Debug, Clone, Copy)]\n");
58        out.push_str(&format!("pub struct {struct_name};\n\n"));
59
60        out.push_str(&format!(
61            "impl typesec_core::role::Role for {struct_name} {{\n"
62        ));
63        out.push_str(&format!(
64            "    fn name() -> &'static str {{ {:?} }}\n",
65            role.name
66        ));
67
68        // Build effective permission list (including inherited, flattened).
69        let effective_perms = collect_all_permissions(&role.name, policy);
70        let perms_literal = format_str_slice(&effective_perms);
71        out.push_str(&format!(
72            "    fn permission_names() -> &'static [&'static str] {{ &{perms_literal} }}\n"
73        ));
74
75        // Collect all resource patterns (own + inherited).
76        let effective_resources = collect_all_resources(&role.name, policy);
77        let resources_literal = format_str_slice(&effective_resources);
78        out.push_str(&format!(
79            "    fn resource_patterns() -> &'static [&'static str] {{ &{resources_literal} }}\n"
80        ));
81
82        out.push_str("}\n\n");
83    }
84
85    out
86}
87
88fn collect_all_permissions(role_name: &str, policy: &RbacPolicy) -> Vec<String> {
89    let mut seen_roles = std::collections::HashSet::new();
90    let mut perms = std::collections::BTreeSet::new();
91    collect_perms_inner(role_name, policy, &mut seen_roles, &mut perms);
92    perms.into_iter().collect()
93}
94
95fn collect_perms_inner(
96    role_name: &str,
97    policy: &RbacPolicy,
98    seen: &mut std::collections::HashSet<String>,
99    out: &mut std::collections::BTreeSet<String>,
100) {
101    if !seen.insert(role_name.to_owned()) {
102        return;
103    }
104    if let Some(role) = policy.roles.iter().find(|r| r.name == role_name) {
105        for p in &role.permissions {
106            out.insert(p.clone());
107        }
108        for parent in &role.inherits {
109            collect_perms_inner(parent, policy, seen, out);
110        }
111    }
112}
113
114fn collect_all_resources(role_name: &str, policy: &RbacPolicy) -> Vec<String> {
115    let mut seen_roles = std::collections::HashSet::new();
116    let mut resources = std::collections::BTreeSet::new();
117    collect_resources_inner(role_name, policy, &mut seen_roles, &mut resources);
118    resources.into_iter().collect()
119}
120
121fn collect_resources_inner(
122    role_name: &str,
123    policy: &RbacPolicy,
124    seen: &mut std::collections::HashSet<String>,
125    out: &mut std::collections::BTreeSet<String>,
126) {
127    if !seen.insert(role_name.to_owned()) {
128        return;
129    }
130    if let Some(role) = policy.roles.iter().find(|r| r.name == role_name) {
131        for r in &role.resources {
132            out.insert(r.clone());
133        }
134        for parent in &role.inherits {
135            collect_resources_inner(parent, policy, seen, out);
136        }
137    }
138}
139
140/// Convert `snake_case` or `kebab-case` to `PascalCase`.
141fn to_pascal_case(s: &str) -> String {
142    s.split(['_', '-'])
143        .filter(|p| !p.is_empty())
144        .map(|p| {
145            let mut chars = p.chars();
146            match chars.next() {
147                None => String::new(),
148                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
149            }
150        })
151        .collect()
152}
153
154/// Format a `Vec<String>` as a Rust `[&'static str; N]` literal.
155fn format_str_slice(items: &[String]) -> String {
156    let inner: Vec<String> = items.iter().map(|s| format!("{s:?}")).collect();
157    format!("[{}]", inner.join(", "))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::model::RbacPolicy;
164
165    const YAML: &str = r#"
166roles:
167  - name: analyst
168    permissions: [read, read_sensitive]
169    resources: ["reports/*"]
170  - name: admin
171    inherits: [analyst]
172    permissions: [write, delete]
173    resources: ["*"]
174assignments: []
175"#;
176
177    #[test]
178    fn generates_rust_structs() {
179        let policy = RbacPolicy::from_yaml(YAML).expect("parse ok");
180        let code = generate_rust(&policy);
181        assert!(code.contains("pub struct Analyst"));
182        assert!(code.contains("pub struct Admin"));
183        assert!(code.contains("fn name() -> &'static str { \"analyst\" }"));
184        // Admin should inherit analyst's permissions
185        assert!(code.contains("read_sensitive"));
186    }
187
188    #[test]
189    fn pascal_case_conversion() {
190        assert_eq!(super::to_pascal_case("data_analyst"), "DataAnalyst");
191        assert_eq!(super::to_pascal_case("deploy-bot"), "DeployBot");
192        assert_eq!(super::to_pascal_case("admin"), "Admin");
193    }
194}