Skip to main content

systemprompt_cli/commands/admin/access_control/
export.rs

1//! `admin access-control export` command emitting grouped rules as YAML.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::BTreeMap;
7
8use anyhow::{Result, anyhow};
9use systemprompt_database::DbPool;
10use systemprompt_runtime::AppContext;
11use systemprompt_security::authz::repository::AccessControlRepository;
12
13use super::ExportYamlArgs;
14use crate::CliConfig;
15use crate::shared::CommandOutput;
16
17pub(super) async fn run(_args: ExportYamlArgs, _config: &CliConfig) -> Result<CommandOutput> {
18    let ctx = AppContext::new().await?;
19    let yaml = render_yaml_snapshot(ctx.db_pool()).await?;
20    Ok(CommandOutput::text_titled(
21        "Access-control baseline (paste into services/access-control YAML)",
22        yaml,
23    ))
24}
25
26pub async fn render_yaml_snapshot(pool: &DbPool) -> Result<String> {
27    let grouped = load_grouped_rules(pool).await?;
28
29    let mut out = String::new();
30    out.push_str("# Generated by `systemprompt admin access-control export-yaml`\n");
31    out.push_str("# This snapshot reflects this instance's DB at export time.\n");
32    out.push_str("# Per-user overrides (rule_type='user') are intentionally omitted.\n\n");
33    write_rules(&mut out, &grouped);
34    Ok(out)
35}
36
37async fn load_grouped_rules(pool: &DbPool) -> Result<BTreeMap<GroupKey, GroupValue>> {
38    let repo = AccessControlRepository::new(pool).map_err(|e| anyhow!("acquire repo: {e}"))?;
39    let rows = repo
40        .list_role_rules_for_export()
41        .await
42        .map_err(|e| anyhow!("query access_control_rules: {e}"))?;
43
44    let mut grouped: BTreeMap<GroupKey, GroupValue> = BTreeMap::new();
45    for row in rows {
46        let key = GroupKey {
47            entity_type: row.entity_type,
48            entity_id: row.entity_id,
49            access: row.access,
50            justification: row.justification.clone(),
51        };
52        let entry = grouped.entry(key).or_default();
53        if row.rule_type == "role" {
54            entry.roles.push(row.rule_value);
55        }
56    }
57    Ok(grouped)
58}
59
60fn write_rules(out: &mut String, grouped: &BTreeMap<GroupKey, GroupValue>) {
61    out.push_str("rules:\n");
62    if grouped.is_empty() {
63        out.push_str("  []\n");
64        return;
65    }
66    for (key, value) in grouped {
67        write_rule(out, key, value);
68    }
69}
70
71fn write_rule(out: &mut String, key: &GroupKey, value: &GroupValue) {
72    out.push_str("  - entity_type: ");
73    out.push_str(&yaml_scalar(&key.entity_type));
74    out.push('\n');
75    out.push_str("    entity_id: ");
76    out.push_str(&yaml_scalar(&key.entity_id));
77    out.push('\n');
78    out.push_str("    access: ");
79    out.push_str(&yaml_scalar(&key.access));
80    out.push('\n');
81    write_string_array(out, "    roles", &value.roles);
82    if let Some(j) = &key.justification {
83        out.push_str("    justification: ");
84        out.push_str(&yaml_scalar(j));
85        out.push('\n');
86    }
87}
88
89fn write_string_array(out: &mut String, key: &str, items: &[String]) {
90    if items.is_empty() {
91        return;
92    }
93    out.push_str(key);
94    out.push_str(": [");
95    out.push_str(
96        &items
97            .iter()
98            .map(|s| yaml_scalar(s))
99            .collect::<Vec<_>>()
100            .join(", "),
101    );
102    out.push_str("]\n");
103}
104
105#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
106struct GroupKey {
107    entity_type: String,
108    entity_id: String,
109    access: String,
110    justification: Option<String>,
111}
112
113#[derive(Debug, Default)]
114struct GroupValue {
115    roles: Vec<String>,
116}
117
118fn yaml_scalar(s: &str) -> String {
119    let needs_quotes = s.is_empty()
120        || s.contains([':', '#', '\n', '"', '\'', '\\'])
121        || s.starts_with([
122            '-', '?', '!', '&', '*', '[', ']', '{', '}', '|', '>', '%', '@', '`', ' ',
123        ])
124        || s.trim() != s
125        || matches!(
126            s.to_lowercase().as_str(),
127            "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
128        )
129        || s.parse::<f64>().is_ok();
130    if needs_quotes {
131        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
132        format!("\"{escaped}\"")
133    } else {
134        s.to_owned()
135    }
136}