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