typesec-rbac 0.6.0

RBAC policy engine for typesec — YAML → typed enforcement
Documentation
//! Code generator: RBAC YAML → Rust source.
//!
//! `typesec generate --policy rbac.yaml --out src/policy_gen.rs` calls
//! [`generate_rust`] and writes the result to a file.
//!
//! ## What gets generated?
//!
//! For each role in the YAML, the generator emits:
//!
//! ```rust,ignore
//! // Auto-generated by typesec-cli — DO NOT EDIT
//! use typesec_core::role::Role;
//!
//! pub struct Analyst;
//! impl Role for Analyst {
//!     fn name() -> &'static str { "analyst" }
//!     fn permission_names() -> &'static [&'static str] { &["read", "read_sensitive"] }
//!     fn resource_patterns() -> &'static [&'static str] { &["reports/*", "metrics/*"] }
//! }
//! ```
//!
//! These typed role structs let the compiler verify that the roles you reference
//! in application code actually exist in the policy file. If you rename a role in
//! YAML and regenerate, any code referencing the old name will fail to compile.

use crate::model::RbacPolicy;

/// Generate Rust source code for the roles defined in `policy`.
///
/// The output is a complete `.rs` file ready to be `include!`'d or written to disk.
pub fn generate_rust(policy: &RbacPolicy) -> String {
    let mut out = String::new();

    out.push_str("// Auto-generated by typesec-cli — DO NOT EDIT\n");
    out.push_str("// Source: RBAC policy file\n");
    out.push_str("//\n");
    out.push_str("// Each struct below corresponds to a role in the policy YAML.\n");
    out.push_str("// Changing a role name in YAML and regenerating will cause compile\n");
    out.push_str("// errors in any code that references the old struct name — which is\n");
    out.push_str("// the point: the compiler enforces policy consistency.\n\n");
    out.push_str("#[allow(dead_code, non_camel_case_types)]\n");

    for role in &policy.roles {
        let struct_name = to_pascal_case(&role.name);

        out.push_str(&format!("/// Role `{}`.\n", role.name));
        if !role.permissions.is_empty() {
            out.push_str(&format!(
                "/// Permissions: {}.\n",
                role.permissions.join(", ")
            ));
        }
        if !role.resources.is_empty() {
            out.push_str(&format!("/// Resources: {}.\n", role.resources.join(", ")));
        }

        out.push_str("#[derive(Debug, Clone, Copy)]\n");
        out.push_str(&format!("pub struct {struct_name};\n\n"));

        out.push_str(&format!(
            "impl typesec_core::role::Role for {struct_name} {{\n"
        ));
        out.push_str(&format!(
            "    fn name() -> &'static str {{ {:?} }}\n",
            role.name
        ));

        // Build effective permission list (including inherited, flattened).
        let effective_perms = collect_all_permissions(&role.name, policy);
        let perms_literal = format_str_slice(&effective_perms);
        out.push_str(&format!(
            "    fn permission_names() -> &'static [&'static str] {{ &{perms_literal} }}\n"
        ));

        // Collect all resource patterns (own + inherited).
        let effective_resources = collect_all_resources(&role.name, policy);
        let resources_literal = format_str_slice(&effective_resources);
        out.push_str(&format!(
            "    fn resource_patterns() -> &'static [&'static str] {{ &{resources_literal} }}\n"
        ));

        out.push_str("}\n\n");
    }

    out
}

fn collect_all_permissions(role_name: &str, policy: &RbacPolicy) -> Vec<String> {
    let mut seen_roles = std::collections::HashSet::new();
    let mut perms = std::collections::BTreeSet::new();
    collect_perms_inner(role_name, policy, &mut seen_roles, &mut perms);
    perms.into_iter().collect()
}

fn collect_perms_inner(
    role_name: &str,
    policy: &RbacPolicy,
    seen: &mut std::collections::HashSet<String>,
    out: &mut std::collections::BTreeSet<String>,
) {
    if !seen.insert(role_name.to_owned()) {
        return;
    }
    if let Some(role) = policy.roles.iter().find(|r| r.name == role_name) {
        for p in &role.permissions {
            out.insert(p.clone());
        }
        for parent in &role.inherits {
            collect_perms_inner(parent, policy, seen, out);
        }
    }
}

fn collect_all_resources(role_name: &str, policy: &RbacPolicy) -> Vec<String> {
    let mut seen_roles = std::collections::HashSet::new();
    let mut resources = std::collections::BTreeSet::new();
    collect_resources_inner(role_name, policy, &mut seen_roles, &mut resources);
    resources.into_iter().collect()
}

fn collect_resources_inner(
    role_name: &str,
    policy: &RbacPolicy,
    seen: &mut std::collections::HashSet<String>,
    out: &mut std::collections::BTreeSet<String>,
) {
    if !seen.insert(role_name.to_owned()) {
        return;
    }
    if let Some(role) = policy.roles.iter().find(|r| r.name == role_name) {
        for r in &role.resources {
            out.insert(r.clone());
        }
        for parent in &role.inherits {
            collect_resources_inner(parent, policy, seen, out);
        }
    }
}

/// Convert `snake_case` or `kebab-case` to `PascalCase`.
fn to_pascal_case(s: &str) -> String {
    s.split(['_', '-'])
        .filter(|p| !p.is_empty())
        .map(|p| {
            let mut chars = p.chars();
            match chars.next() {
                None => String::new(),
                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
            }
        })
        .collect()
}

/// Format a `Vec<String>` as a Rust `[&'static str; N]` literal.
fn format_str_slice(items: &[String]) -> String {
    let inner: Vec<String> = items.iter().map(|s| format!("{s:?}")).collect();
    format!("[{}]", inner.join(", "))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::RbacPolicy;

    const YAML: &str = r#"
roles:
  - name: analyst
    permissions: [read, read_sensitive]
    resources: ["reports/*"]
  - name: admin
    inherits: [analyst]
    permissions: [write, delete]
    resources: ["*"]
assignments: []
"#;

    #[test]
    fn generates_rust_structs() {
        let policy = RbacPolicy::from_yaml(YAML).expect("parse ok");
        let code = generate_rust(&policy);
        assert!(code.contains("pub struct Analyst"));
        assert!(code.contains("pub struct Admin"));
        assert!(code.contains("fn name() -> &'static str { \"analyst\" }"));
        // Admin should inherit analyst's permissions
        assert!(code.contains("read_sensitive"));
    }

    #[test]
    fn pascal_case_conversion() {
        assert_eq!(super::to_pascal_case("data_analyst"), "DataAnalyst");
        assert_eq!(super::to_pascal_case("deploy-bot"), "DeployBot");
        assert_eq!(super::to_pascal_case("admin"), "Admin");
    }
}