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