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;
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}