systemprompt_cli/commands/admin/access_control/
export.rs1use 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}