systemprompt-cli 0.8.0

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
//! `systemprompt admin access-control` — DB → YAML export channel.
//!
//! Exposes a single subcommand, `export-yaml`, the explicit one-time
//! promotion path: it reads role/department rules from
//! `access_control_rules` and prints them as a YAML snippet matching
//! `AccessControlConfig`. Stdout-only — never writes a file. The operator
//! pastes the output into the committed YAML baseline and redeploys.
//!
//! Per-user overrides (`rule_type='user'`) are operational state and are
//! intentionally excluded from the export.

use std::collections::{BTreeMap, BTreeSet};

use anyhow::{Result, anyhow};
use clap::{Args, Subcommand};
use systemprompt_database::DbPool;
use systemprompt_runtime::AppContext;

use crate::CliConfig;
use crate::shared::{CommandResult, render_result};

#[derive(Debug, Clone, Copy, Subcommand)]
pub enum AccessControlCommands {
    #[command(
        about = "Print current role/department rules as a YAML snippet for promotion to the \
                 committed baseline"
    )]
    ExportYaml(ExportYamlArgs),
}

#[derive(Debug, Clone, Copy, Args)]
pub struct ExportYamlArgs;

pub async fn execute(cmd: AccessControlCommands, config: &CliConfig) -> Result<()> {
    match cmd {
        AccessControlCommands::ExportYaml(args) => {
            let result = export_yaml(args, config).await?;
            render_result(&result);
            Ok(())
        },
    }
}

async fn export_yaml(_args: ExportYamlArgs, _config: &CliConfig) -> Result<CommandResult<String>> {
    let ctx = AppContext::new().await?;
    let yaml = render_yaml_snapshot(ctx.db_pool()).await?;
    Ok(CommandResult::copy_paste(yaml)
        .with_title("Access-control baseline (paste into services/access-control YAML)"))
}

async fn render_yaml_snapshot(pool: &DbPool) -> Result<String> {
    let grouped = load_grouped_rules(pool).await?;
    let declared_departments = collect_referenced_departments(&grouped);

    let mut out = String::new();
    out.push_str("# Generated by `systemprompt admin access-control export-yaml`\n");
    out.push_str("# This snapshot reflects this instance's DB at export time.\n");
    out.push_str("# Per-user overrides (rule_type='user') are intentionally omitted.\n\n");
    write_departments(&mut out, &declared_departments);
    out.push('\n');
    write_rules(&mut out, &grouped);
    Ok(out)
}

async fn load_grouped_rules(pool: &DbPool) -> Result<BTreeMap<GroupKey, GroupValue>> {
    let pg = pool.pool_arc().map_err(|e| anyhow!("acquire pool: {e}"))?;
    let rows = sqlx::query!(
        r#"
        SELECT entity_type, entity_id, rule_type, rule_value, access, justification
        FROM access_control_rules
        WHERE rule_type IN ('role', 'department')
          AND rule_value <> '__default__'
        ORDER BY entity_type, entity_id, access, rule_type, rule_value
        "#,
    )
    .fetch_all(&*pg)
    .await
    .map_err(|e| anyhow!("query access_control_rules: {e}"))?;

    let mut grouped: BTreeMap<GroupKey, GroupValue> = BTreeMap::new();
    for row in rows {
        let key = GroupKey {
            entity_type: row.entity_type,
            entity_id: row.entity_id,
            access: row.access,
            justification: row.justification.clone(),
        };
        let entry = grouped.entry(key).or_default();
        match row.rule_type.as_str() {
            "role" => entry.roles.push(row.rule_value),
            "department" => {
                entry.departments.push(row.rule_value.clone());
                entry.referenced_departments.push(row.rule_value);
            },
            _ => {},
        }
    }
    Ok(grouped)
}

fn collect_referenced_departments(grouped: &BTreeMap<GroupKey, GroupValue>) -> BTreeSet<String> {
    let mut set = BTreeSet::new();
    for v in grouped.values() {
        for d in &v.referenced_departments {
            set.insert(d.clone());
        }
    }
    set
}

fn write_departments(out: &mut String, declared: &BTreeSet<String>) {
    out.push_str("departments:\n");
    if declared.is_empty() {
        out.push_str("  []\n");
        return;
    }
    for name in declared {
        out.push_str(&format!("  - name: {}\n", yaml_scalar(name)));
    }
}

fn write_rules(out: &mut String, grouped: &BTreeMap<GroupKey, GroupValue>) {
    out.push_str("rules:\n");
    if grouped.is_empty() {
        out.push_str("  []\n");
        return;
    }
    for (key, value) in grouped {
        write_rule(out, key, value);
    }
}

fn write_rule(out: &mut String, key: &GroupKey, value: &GroupValue) {
    out.push_str("  - entity_type: ");
    out.push_str(&yaml_scalar(&key.entity_type));
    out.push('\n');
    out.push_str("    entity_id: ");
    out.push_str(&yaml_scalar(&key.entity_id));
    out.push('\n');
    out.push_str("    access: ");
    out.push_str(&yaml_scalar(&key.access));
    out.push('\n');
    write_string_array(out, "    roles", &value.roles);
    write_string_array(out, "    departments", &value.departments);
    if let Some(j) = &key.justification {
        out.push_str("    justification: ");
        out.push_str(&yaml_scalar(j));
        out.push('\n');
    }
}

fn write_string_array(out: &mut String, key: &str, items: &[String]) {
    if items.is_empty() {
        return;
    }
    out.push_str(key);
    out.push_str(": [");
    out.push_str(
        &items
            .iter()
            .map(|s| yaml_scalar(s))
            .collect::<Vec<_>>()
            .join(", "),
    );
    out.push_str("]\n");
}

#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
struct GroupKey {
    entity_type: String,
    entity_id: String,
    access: String,
    justification: Option<String>,
}

#[derive(Debug, Default)]
struct GroupValue {
    roles: Vec<String>,
    departments: Vec<String>,
    referenced_departments: Vec<String>,
}

fn yaml_scalar(s: &str) -> String {
    let needs_quotes = s.is_empty()
        || s.contains([':', '#', '\n', '"', '\'', '\\'])
        || s.starts_with([
            '-', '?', '!', '&', '*', '[', ']', '{', '}', '|', '>', '%', '@', '`', ' ',
        ])
        || s.trim() != s
        || matches!(
            s.to_lowercase().as_str(),
            "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
        )
        || s.parse::<f64>().is_ok();
    if needs_quotes {
        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
        format!("\"{escaped}\"")
    } else {
        s.to_string()
    }
}