Skip to main content

systemprompt_cli/commands/admin/
access_control.rs

1//! `systemprompt admin access-control` — DB → YAML export channel and
2//! catalog/lint inspector.
3//!
4//! Subcommands:
5//!
6//! * `export-yaml` — read role/department rules from `access_control_rules` and
7//!   print them as a YAML snippet matching `AccessControlConfig`. Stdout-only —
8//!   never writes a file. The operator pastes the output into the committed
9//!   YAML baseline and redeploys. Per-user overrides (`rule_type='user'`) are
10//!   operational state and intentionally excluded.
11//!
12//! * `lint` — read the live `access_control_entities` and
13//!   `access_control_rules` tables, then report unknown entities (rules
14//!   pointing at no catalog row — only possible if the FK was bypassed
15//!   manually, e.g. mid-migration) and unreachable rules (catalog rows with
16//!   `default_included=false` and zero grant rows — entity exists but no user
17//!   can ever reach it). Exits non-zero on any finding so it can gate CI.
18
19use 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
78/// Iterate every `EntityKind` and report:
79///
80/// * **Unknown entities** — rows in `access_control_rules` whose `(entity_type,
81///   entity_id)` has no matching catalog row. The FK added in migration 007
82///   makes this impossible going forward, but the check is cheap and catches
83///   manual SQL fixes that bypass the schema.
84/// * **Unreachable entities** — catalog rows with `default_included = false`
85///   and zero matching grants. The entity is registered but no one can reach
86///   it.
87///
88/// Returns `(human_report, exit_nonzero)`.
89async 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}