systemprompt-security 0.9.0

Security infrastructure for systemprompt.io AI governance: JWT, OAuth2 token extraction, scope enforcement, ChaCha20-Poly1305 secret encryption, the four-layer tool-call governance pipeline, and the unified authz decision plane (deny-overrides resolver + AuthzDecisionHook) shared by gateway and MCP enforcement.
Documentation
//! Bootstrap-time projection of [`AccessControlConfig`] into
//! `access_control_rules`.
//!
//! This is the only sanctioned YAML → DB ingestion path for authorization
//! rules. It mirrors `systemprompt_agent::services::AgentIngestionService`
//! and the content/skill ingestion services in spirit: a domain object
//! parsed from disk is upserted into a typed repository, with explicit
//! `override_existing` and `delete_orphans` knobs.
//!
//! Direction is fixed (YAML → DB). There is no opposite. Per-user
//! overrides (`rule_type='user'`) are runtime state and are *never*
//! touched here, regardless of `delete_orphans`. Sentinel default-included
//! rows (`rule_value='__default__'`) are also preserved.

use std::sync::Arc;

use sqlx::PgPool;
use systemprompt_database::DbPool;
use systemprompt_identifiers::RuleId;

use super::config::{AccessControlConfig, RuleEntry};
use super::error::{AuthzError, AuthzResult};
use super::types::RuleType;

const DEFAULT_SENTINEL_VALUE: &str = "__default__";

#[derive(Debug, Clone, Copy, Default)]
pub struct IngestOptions {
    pub override_existing: bool,
    pub delete_orphans: bool,
}

#[derive(Debug, Clone, Copy, Default)]
pub struct IngestReport {
    pub departments_declared: usize,
    pub rules_inserted: usize,
    pub rules_updated: usize,
    pub rules_skipped: usize,
    pub rules_deleted: usize,
}

#[derive(Debug, Clone)]
pub struct AccessControlIngestionService {
    write_pool: Arc<PgPool>,
}

impl AccessControlIngestionService {
    pub fn new(db: &DbPool) -> AuthzResult<Self> {
        let write_pool = db
            .write_pool_arc()
            .map_err(|err| AuthzError::Validation(err.to_string()))?;
        Ok(Self { write_pool })
    }

    pub const fn from_pool(pool: Arc<PgPool>) -> Self {
        Self { write_pool: pool }
    }

    pub async fn ingest_config(
        &self,
        cfg: &AccessControlConfig,
        options: IngestOptions,
    ) -> AuthzResult<IngestReport> {
        cfg.validate()?;

        let targets = expand_targets(&cfg.rules);

        let mut tx = self.write_pool.begin().await?;
        let mut report = IngestReport {
            departments_declared: cfg.departments.len(),
            ..IngestReport::default()
        };

        if options.delete_orphans {
            let res = sqlx::query!(
                r#"
                DELETE FROM access_control_rules
                WHERE rule_type IN ('role', 'department')
                  AND rule_value <> $1
                "#,
                DEFAULT_SENTINEL_VALUE,
            )
            .execute(&mut *tx)
            .await?;
            report.rules_deleted = res.rows_affected() as usize;
        }

        for target in &targets {
            let outcome = upsert_target(&mut tx, target, options.override_existing).await?;
            match outcome {
                UpsertOutcome::Inserted => report.rules_inserted += 1,
                UpsertOutcome::Updated => report.rules_updated += 1,
                UpsertOutcome::Skipped => report.rules_skipped += 1,
            }
        }

        tx.commit().await?;

        tracing::info!(
            target = "bootstrap_access_control_loaded",
            departments_declared = report.departments_declared,
            rules_inserted = report.rules_inserted,
            rules_updated = report.rules_updated,
            rules_skipped = report.rules_skipped,
            rules_deleted = report.rules_deleted,
            override_existing = options.override_existing,
            delete_orphans = options.delete_orphans,
            "access-control YAML ingested",
        );

        Ok(report)
    }
}

#[derive(Debug)]
struct Target<'a> {
    entity_type: &'a str,
    entity_id: &'a str,
    rule_type: RuleType,
    rule_value: &'a str,
    access: &'static str,
    justification: Option<&'a str>,
}

fn expand_targets(rules: &[RuleEntry]) -> Vec<Target<'_>> {
    let mut out = Vec::with_capacity(rules.len());
    for rule in rules {
        let access_str = match rule.access {
            super::types::Access::Allow => "allow",
            super::types::Access::Deny => "deny",
        };
        for role in &rule.roles {
            out.push(Target {
                entity_type: rule.entity_type.as_str(),
                entity_id: rule.entity_id.as_str(),
                rule_type: RuleType::Role,
                rule_value: role.as_str(),
                access: access_str,
                justification: rule.justification.as_deref(),
            });
        }
        for dept in &rule.departments {
            out.push(Target {
                entity_type: rule.entity_type.as_str(),
                entity_id: rule.entity_id.as_str(),
                rule_type: RuleType::Department,
                rule_value: dept.as_str(),
                access: access_str,
                justification: rule.justification.as_deref(),
            });
        }
    }
    out
}

#[derive(Debug, Clone, Copy)]
enum UpsertOutcome {
    Inserted,
    Updated,
    Skipped,
}

async fn upsert_target(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    target: &Target<'_>,
    override_existing: bool,
) -> AuthzResult<UpsertOutcome> {
    let existing = sqlx::query!(
        r#"
        SELECT id, access, justification
        FROM access_control_rules
        WHERE entity_type = $1 AND entity_id = $2
          AND rule_type = $3 AND rule_value = $4
        "#,
        target.entity_type,
        target.entity_id,
        target.rule_type.to_string(),
        target.rule_value,
    )
    .fetch_optional(&mut **tx)
    .await?;

    if let Some(row) = existing {
        if !override_existing {
            return Ok(UpsertOutcome::Skipped);
        }
        let unchanged =
            row.access == target.access && row.justification.as_deref() == target.justification;
        if unchanged {
            return Ok(UpsertOutcome::Skipped);
        }
        sqlx::query!(
            r#"
            UPDATE access_control_rules
            SET access = $2,
                justification = $3,
                updated_at = NOW()
            WHERE id = $1
            "#,
            row.id,
            target.access,
            target.justification,
        )
        .execute(&mut **tx)
        .await?;
        Ok(UpsertOutcome::Updated)
    } else {
        let id = RuleId::generate();
        sqlx::query!(
            r#"
            INSERT INTO access_control_rules
                (id, entity_type, entity_id, rule_type, rule_value, access,
                 default_included, justification)
            VALUES ($1, $2, $3, $4, $5, $6, false, $7)
            "#,
            id.as_str(),
            target.entity_type,
            target.entity_id,
            target.rule_type.to_string(),
            target.rule_value,
            target.access,
            target.justification,
        )
        .execute(&mut **tx)
        .await?;
        Ok(UpsertOutcome::Inserted)
    }
}