systemprompt_cli/commands/admin/
access_control.rs1use std::collections::{BTreeMap, BTreeSet};
13
14use anyhow::{Result, anyhow};
15use clap::{Args, Subcommand};
16use systemprompt_database::DbPool;
17use systemprompt_runtime::AppContext;
18
19use crate::CliConfig;
20use crate::shared::{CommandResult, render_result};
21
22#[derive(Debug, Clone, Copy, Subcommand)]
23pub enum AccessControlCommands {
24 #[command(
25 about = "Print current role/department rules as a YAML snippet for promotion to the \
26 committed baseline"
27 )]
28 ExportYaml(ExportYamlArgs),
29}
30
31#[derive(Debug, Clone, Copy, Args)]
32pub struct ExportYamlArgs;
33
34pub async fn execute(cmd: AccessControlCommands, config: &CliConfig) -> Result<()> {
35 match cmd {
36 AccessControlCommands::ExportYaml(args) => {
37 let result = export_yaml(args, config).await?;
38 render_result(&result);
39 Ok(())
40 },
41 }
42}
43
44async fn export_yaml(_args: ExportYamlArgs, _config: &CliConfig) -> Result<CommandResult<String>> {
45 let ctx = AppContext::new().await?;
46 let yaml = render_yaml_snapshot(ctx.db_pool()).await?;
47 Ok(CommandResult::copy_paste(yaml)
48 .with_title("Access-control baseline (paste into services/access-control YAML)"))
49}
50
51async fn render_yaml_snapshot(pool: &DbPool) -> Result<String> {
52 let grouped = load_grouped_rules(pool).await?;
53 let declared_departments = collect_referenced_departments(&grouped);
54
55 let mut out = String::new();
56 out.push_str("# Generated by `systemprompt admin access-control export-yaml`\n");
57 out.push_str("# This snapshot reflects this instance's DB at export time.\n");
58 out.push_str("# Per-user overrides (rule_type='user') are intentionally omitted.\n\n");
59 write_departments(&mut out, &declared_departments);
60 out.push('\n');
61 write_rules(&mut out, &grouped);
62 Ok(out)
63}
64
65async fn load_grouped_rules(pool: &DbPool) -> Result<BTreeMap<GroupKey, GroupValue>> {
66 let pg = pool.pool_arc().map_err(|e| anyhow!("acquire pool: {e}"))?;
67 let rows = sqlx::query!(
68 r#"
69 SELECT entity_type, entity_id, rule_type, rule_value, access, justification
70 FROM access_control_rules
71 WHERE rule_type IN ('role', 'department')
72 AND rule_value <> '__default__'
73 ORDER BY entity_type, entity_id, access, rule_type, rule_value
74 "#,
75 )
76 .fetch_all(&*pg)
77 .await
78 .map_err(|e| anyhow!("query access_control_rules: {e}"))?;
79
80 let mut grouped: BTreeMap<GroupKey, GroupValue> = BTreeMap::new();
81 for row in rows {
82 let key = GroupKey {
83 entity_type: row.entity_type,
84 entity_id: row.entity_id,
85 access: row.access,
86 justification: row.justification.clone(),
87 };
88 let entry = grouped.entry(key).or_default();
89 match row.rule_type.as_str() {
90 "role" => entry.roles.push(row.rule_value),
91 "department" => {
92 entry.departments.push(row.rule_value.clone());
93 entry.referenced_departments.push(row.rule_value);
94 },
95 _ => {},
96 }
97 }
98 Ok(grouped)
99}
100
101fn collect_referenced_departments(grouped: &BTreeMap<GroupKey, GroupValue>) -> BTreeSet<String> {
102 let mut set = BTreeSet::new();
103 for v in grouped.values() {
104 for d in &v.referenced_departments {
105 set.insert(d.clone());
106 }
107 }
108 set
109}
110
111fn write_departments(out: &mut String, declared: &BTreeSet<String>) {
112 out.push_str("departments:\n");
113 if declared.is_empty() {
114 out.push_str(" []\n");
115 return;
116 }
117 for name in declared {
118 out.push_str(&format!(" - name: {}\n", yaml_scalar(name)));
119 }
120}
121
122fn write_rules(out: &mut String, grouped: &BTreeMap<GroupKey, GroupValue>) {
123 out.push_str("rules:\n");
124 if grouped.is_empty() {
125 out.push_str(" []\n");
126 return;
127 }
128 for (key, value) in grouped {
129 write_rule(out, key, value);
130 }
131}
132
133fn write_rule(out: &mut String, key: &GroupKey, value: &GroupValue) {
134 out.push_str(" - entity_type: ");
135 out.push_str(&yaml_scalar(&key.entity_type));
136 out.push('\n');
137 out.push_str(" entity_id: ");
138 out.push_str(&yaml_scalar(&key.entity_id));
139 out.push('\n');
140 out.push_str(" access: ");
141 out.push_str(&yaml_scalar(&key.access));
142 out.push('\n');
143 write_string_array(out, " roles", &value.roles);
144 write_string_array(out, " departments", &value.departments);
145 if let Some(j) = &key.justification {
146 out.push_str(" justification: ");
147 out.push_str(&yaml_scalar(j));
148 out.push('\n');
149 }
150}
151
152fn write_string_array(out: &mut String, key: &str, items: &[String]) {
153 if items.is_empty() {
154 return;
155 }
156 out.push_str(key);
157 out.push_str(": [");
158 out.push_str(
159 &items
160 .iter()
161 .map(|s| yaml_scalar(s))
162 .collect::<Vec<_>>()
163 .join(", "),
164 );
165 out.push_str("]\n");
166}
167
168#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
169struct GroupKey {
170 entity_type: String,
171 entity_id: String,
172 access: String,
173 justification: Option<String>,
174}
175
176#[derive(Debug, Default)]
177struct GroupValue {
178 roles: Vec<String>,
179 departments: Vec<String>,
180 referenced_departments: Vec<String>,
181}
182
183fn yaml_scalar(s: &str) -> String {
184 let needs_quotes = s.is_empty()
185 || s.contains([':', '#', '\n', '"', '\'', '\\'])
186 || s.starts_with([
187 '-', '?', '!', '&', '*', '[', ']', '{', '}', '|', '>', '%', '@', '`', ' ',
188 ])
189 || s.trim() != s
190 || matches!(
191 s.to_lowercase().as_str(),
192 "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
193 )
194 || s.parse::<f64>().is_ok();
195 if needs_quotes {
196 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
197 format!("\"{escaped}\"")
198 } else {
199 s.to_string()
200 }
201}