Skip to main content

systemprompt_cli/commands/admin/
access_control.rs

1//! `systemprompt admin access-control` — DB → YAML export channel.
2//!
3//! Exposes a single subcommand, `export-yaml`, the explicit one-time
4//! promotion path: it reads role/department rules from
5//! `access_control_rules` and prints them as a YAML snippet matching
6//! `AccessControlConfig`. Stdout-only — never writes a file. The operator
7//! pastes the output into the committed YAML baseline and redeploys.
8//!
9//! Per-user overrides (`rule_type='user'`) are operational state and are
10//! intentionally excluded from the export.
11
12use 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}