Skip to main content

systemprompt_cli/commands/admin/access_control/
export.rs

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