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