systemprompt_cli/commands/admin/
access_control.rs1use std::collections::{BTreeMap, BTreeSet};
20
21use anyhow::{Result, anyhow};
22use clap::{Args, Subcommand};
23use systemprompt_database::DbPool;
24use systemprompt_runtime::AppContext;
25use systemprompt_security::authz::repository::AccessControlRepository;
26use systemprompt_security::authz::types::EntityKind;
27
28use crate::CliConfig;
29use crate::shared::{CommandResult, render_result};
30
31#[derive(Debug, Clone, Copy, Subcommand)]
32pub enum AccessControlCommands {
33 #[command(
34 about = "Print current role/department rules as a YAML snippet for promotion to the \
35 committed baseline"
36 )]
37 ExportYaml(ExportYamlArgs),
38
39 #[command(
40 about = "Lint the live access-control tables for unknown entities and unreachable rules; \
41 exits non-zero on findings"
42 )]
43 Lint(LintArgs),
44}
45
46#[derive(Debug, Clone, Copy, Args)]
47pub struct ExportYamlArgs;
48
49#[derive(Debug, Clone, Copy, Args)]
50pub struct LintArgs;
51
52pub async fn execute(cmd: AccessControlCommands, config: &CliConfig) -> Result<()> {
53 match cmd {
54 AccessControlCommands::ExportYaml(args) => {
55 let result = export_yaml(args, config).await?;
56 render_result(&result);
57 Ok(())
58 },
59 AccessControlCommands::Lint(args) => {
60 let (text, exit_nonzero) = lint(args, config).await?;
61 let result = CommandResult::raw_text(text).with_title("Access-control lint");
62 render_result(&result);
63 if exit_nonzero {
64 anyhow::bail!("access-control lint failed; see report above");
65 }
66 Ok(())
67 },
68 }
69}
70
71async fn export_yaml(_args: ExportYamlArgs, _config: &CliConfig) -> Result<CommandResult<String>> {
72 let ctx = AppContext::new().await?;
73 let yaml = render_yaml_snapshot(ctx.db_pool()).await?;
74 Ok(CommandResult::raw_text(yaml)
75 .with_title("Access-control baseline (paste into services/access-control YAML)"))
76}
77
78async fn lint(_args: LintArgs, _config: &CliConfig) -> Result<(String, bool)> {
90 let ctx = AppContext::new().await?;
91 let repo =
92 AccessControlRepository::new(ctx.db_pool()).map_err(|e| anyhow!("acquire repo: {e}"))?;
93
94 let mut report = String::new();
95 let mut unknown_total = 0usize;
96 let mut unreachable_total = 0usize;
97
98 for kind in ALL_KINDS {
99 let catalog = repo
100 .list_entities(*kind)
101 .await
102 .map_err(|e| anyhow!("list_entities({kind}): {e}"))?;
103 let catalog_ids: BTreeSet<String> = catalog.iter().map(|e| e.id.clone()).collect();
104
105 let rule_rows = repo
106 .list_role_department_rules_for_export()
107 .await
108 .map_err(|e| anyhow!("list rules: {e}"))?;
109 let rule_ids: BTreeSet<String> = rule_rows
110 .iter()
111 .filter(|r| r.entity_type == kind.as_str())
112 .map(|r| r.entity_id.clone())
113 .collect();
114
115 let unknown: Vec<&String> = rule_ids.difference(&catalog_ids).collect();
116 let unreachable: Vec<&str> = catalog
117 .iter()
118 .filter(|e| !e.default_included && !rule_ids.contains(&e.id))
119 .map(|e| e.id.as_str())
120 .collect();
121
122 if !unknown.is_empty() || !unreachable.is_empty() {
123 report.push_str(&format!("\n[{kind}]\n"));
124 for id in &unknown {
125 report.push_str(&format!(
126 " UNKNOWN {id} (rule rows present, no catalog row)\n"
127 ));
128 }
129 for id in &unreachable {
130 report.push_str(&format!(
131 " UNREACHABLE {id} (catalog row present, default_included=false, no \
132 grants)\n"
133 ));
134 }
135 unknown_total += unknown.len();
136 unreachable_total += unreachable.len();
137 }
138 }
139
140 if unknown_total == 0 && unreachable_total == 0 {
141 Ok(("OK — no access-control findings\n".to_owned(), false))
142 } else {
143 report.insert_str(
144 0,
145 &format!("FAIL — {unknown_total} unknown, {unreachable_total} unreachable\n"),
146 );
147 Ok((report, true))
148 }
149}
150
151const ALL_KINDS: &[EntityKind] = &[
152 EntityKind::GatewayRoute,
153 EntityKind::McpServer,
154 EntityKind::Plugin,
155 EntityKind::Agent,
156 EntityKind::Marketplace,
157 EntityKind::Skill,
158 EntityKind::Hook,
159];
160
161async fn render_yaml_snapshot(pool: &DbPool) -> Result<String> {
162 let grouped = load_grouped_rules(pool).await?;
163 let declared_departments = collect_referenced_departments(&grouped);
164
165 let mut out = String::new();
166 out.push_str("# Generated by `systemprompt admin access-control export-yaml`\n");
167 out.push_str("# This snapshot reflects this instance's DB at export time.\n");
168 out.push_str("# Per-user overrides (rule_type='user') are intentionally omitted.\n\n");
169 write_departments(&mut out, &declared_departments);
170 out.push('\n');
171 write_rules(&mut out, &grouped);
172 Ok(out)
173}
174
175async fn load_grouped_rules(pool: &DbPool) -> Result<BTreeMap<GroupKey, GroupValue>> {
176 let repo = AccessControlRepository::new(pool).map_err(|e| anyhow!("acquire repo: {e}"))?;
177 let rows = repo
178 .list_role_department_rules_for_export()
179 .await
180 .map_err(|e| anyhow!("query access_control_rules: {e}"))?;
181
182 let mut grouped: BTreeMap<GroupKey, GroupValue> = BTreeMap::new();
183 for row in rows {
184 let key = GroupKey {
185 entity_type: row.entity_type,
186 entity_id: row.entity_id,
187 access: row.access,
188 justification: row.justification.clone(),
189 };
190 let entry = grouped.entry(key).or_default();
191 match row.rule_type.as_str() {
192 "role" => entry.roles.push(row.rule_value),
193 "department" => {
194 entry.departments.push(row.rule_value.clone());
195 entry.referenced_departments.push(row.rule_value);
196 },
197 _ => {},
198 }
199 }
200 Ok(grouped)
201}
202
203fn collect_referenced_departments(grouped: &BTreeMap<GroupKey, GroupValue>) -> BTreeSet<String> {
204 let mut set = BTreeSet::new();
205 for v in grouped.values() {
206 for d in &v.referenced_departments {
207 set.insert(d.clone());
208 }
209 }
210 set
211}
212
213fn write_departments(out: &mut String, declared: &BTreeSet<String>) {
214 out.push_str("departments:\n");
215 if declared.is_empty() {
216 out.push_str(" []\n");
217 return;
218 }
219 for name in declared {
220 out.push_str(&format!(" - name: {}\n", yaml_scalar(name)));
221 }
222}
223
224fn write_rules(out: &mut String, grouped: &BTreeMap<GroupKey, GroupValue>) {
225 out.push_str("rules:\n");
226 if grouped.is_empty() {
227 out.push_str(" []\n");
228 return;
229 }
230 for (key, value) in grouped {
231 write_rule(out, key, value);
232 }
233}
234
235fn write_rule(out: &mut String, key: &GroupKey, value: &GroupValue) {
236 out.push_str(" - entity_type: ");
237 out.push_str(&yaml_scalar(&key.entity_type));
238 out.push('\n');
239 out.push_str(" entity_id: ");
240 out.push_str(&yaml_scalar(&key.entity_id));
241 out.push('\n');
242 out.push_str(" access: ");
243 out.push_str(&yaml_scalar(&key.access));
244 out.push('\n');
245 write_string_array(out, " roles", &value.roles);
246 write_string_array(out, " departments", &value.departments);
247 if let Some(j) = &key.justification {
248 out.push_str(" justification: ");
249 out.push_str(&yaml_scalar(j));
250 out.push('\n');
251 }
252}
253
254fn write_string_array(out: &mut String, key: &str, items: &[String]) {
255 if items.is_empty() {
256 return;
257 }
258 out.push_str(key);
259 out.push_str(": [");
260 out.push_str(
261 &items
262 .iter()
263 .map(|s| yaml_scalar(s))
264 .collect::<Vec<_>>()
265 .join(", "),
266 );
267 out.push_str("]\n");
268}
269
270#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
271struct GroupKey {
272 entity_type: String,
273 entity_id: String,
274 access: String,
275 justification: Option<String>,
276}
277
278#[derive(Debug, Default)]
279struct GroupValue {
280 roles: Vec<String>,
281 departments: Vec<String>,
282 referenced_departments: Vec<String>,
283}
284
285fn yaml_scalar(s: &str) -> String {
286 let needs_quotes = s.is_empty()
287 || s.contains([':', '#', '\n', '"', '\'', '\\'])
288 || s.starts_with([
289 '-', '?', '!', '&', '*', '[', ']', '{', '}', '|', '>', '%', '@', '`', ' ',
290 ])
291 || s.trim() != s
292 || matches!(
293 s.to_lowercase().as_str(),
294 "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
295 )
296 || s.parse::<f64>().is_ok();
297 if needs_quotes {
298 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
299 format!("\"{escaped}\"")
300 } else {
301 s.to_owned()
302 }
303}