mod catalog;
pub mod glob;
mod marketplace;
mod messaging;
mod resolve;
mod scope;
mod subjects;
mod upsert;
use std::collections::BTreeSet;
use std::sync::Arc;
use sqlx::PgPool;
use systemprompt_database::DbPool;
use super::config::AccessControlConfig;
use super::error::{AuthzError, AuthzResult};
use super::subject_directory::{RoleDirectory, SharedRoleDirectory, discover_role_directory};
use super::types::RuleType;
pub use catalog::RegisteredEntities;
use resolve::{prune_role_rules, resolve_rules};
pub use scope::IngestScope;
pub use subjects::UnknownSubject;
use subjects::{SubjectMention, find_unknown_subjects};
pub use upsert::{DASHBOARD_SOURCE, YAML_SOURCE};
use upsert::{SOURCE_LABEL, Target, UpsertOutcome, upsert_entity_row, upsert_target};
#[derive(Debug, Clone)]
pub struct IngestOptions {
pub override_existing: bool,
pub delete_orphans: bool,
pub source: String,
pub scope: IngestScope,
}
impl Default for IngestOptions {
fn default() -> Self {
Self {
override_existing: false,
delete_orphans: false,
source: YAML_SOURCE.to_owned(),
scope: IngestScope::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IngestReport {
pub inserted: usize,
pub updated: usize,
pub skipped: usize,
pub deleted: usize,
pub protected: usize,
pub unknown_subjects: Vec<UnknownSubject>,
}
const fn tally(report: &mut IngestReport, outcome: UpsertOutcome) {
match outcome {
UpsertOutcome::Inserted => report.inserted += 1,
UpsertOutcome::Updated => report.updated += 1,
UpsertOutcome::Skipped => report.skipped += 1,
UpsertOutcome::Protected => report.protected += 1,
}
}
#[derive(Debug, Clone)]
pub struct AccessControlIngestionService {
write_pool: Arc<PgPool>,
role_directory: Option<SharedRoleDirectory>,
}
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::from_pool(write_pool))
}
pub fn from_pool(pool: Arc<PgPool>) -> Self {
let role_directory = discover_role_directory(&pool);
Self {
write_pool: pool,
role_directory,
}
}
fn role_directory(&self) -> Option<&dyn RoleDirectory> {
self.role_directory.as_deref()
}
pub async fn ingest_config_from_yaml_path(
&self,
yaml_path: &std::path::Path,
options: IngestOptions,
registered: &RegisteredEntities,
) -> AuthzResult<IngestReport> {
let raw = tokio::fs::read_to_string(yaml_path).await.map_err(|err| {
AuthzError::Validation(format!("failed to read {}: {err}", yaml_path.display()))
})?;
let cfg: AccessControlConfig = serde_yaml::from_str(&raw).map_err(|err| {
AuthzError::Validation(format!(
"failed to parse {} as AccessControlConfig: {err}",
yaml_path.display()
))
})?;
self.ingest_config(&cfg, options, registered).await
}
pub async fn ingest_config(
&self,
cfg: &AccessControlConfig,
options: IngestOptions,
registered: &RegisteredEntities,
) -> AuthzResult<IngestReport> {
cfg.validate()?;
let mut tx = self.write_pool.begin().await?;
let validated = resolve_rules(&mut tx, &cfg.rules, registered).await?;
let resolved = validated.rules();
let mut report = IngestReport::default();
if options.delete_orphans {
report.deleted = prune_role_rules(&mut tx, resolved, &options).await?;
}
let mut mentions = BTreeSet::new();
for rule in resolved {
for id in &rule.ids {
upsert_entity_row(
&mut tx,
rule.entity_kind,
id,
rule.default_included,
SOURCE_LABEL,
)
.await?;
for role in rule.roles {
let target = Target {
entity_kind: rule.entity_kind,
entity_id: id,
rule_type: RuleType::ROLE,
rule_value: role,
access: rule.access,
justification: rule.justification,
source: &options.source,
};
let outcome =
upsert_target(&mut tx, &target, options.override_existing).await?;
tally(&mut report, outcome);
mentions.insert(SubjectMention {
rule_type: RuleType::ROLE.to_string(),
value: role.clone(),
entity: format!("{}:{id}", rule.entity_kind.as_str()),
});
}
}
}
report.unknown_subjects = find_unknown_subjects(self.role_directory(), &mentions).await?;
tx.commit().await?;
tracing::info!(
target = "bootstrap_access_control_loaded",
inserted = report.inserted,
updated = report.updated,
skipped = report.skipped,
deleted = report.deleted,
protected = report.protected,
unknown_subjects = report.unknown_subjects.len(),
source = %options.source,
override_existing = options.override_existing,
delete_orphans = options.delete_orphans,
"access-control YAML ingested",
);
Ok(report)
}
}