Skip to main content

systemprompt_cli/commands/admin/config/
reconcile.rs

1//! Re-materialise the authz catalog after a profile edit.
2//!
3//! Gateway route ids are content-addressed, so changing a route's pattern or
4//! provider mints a new id with no `access_control_entities` row — the next
5//! request would fail closed with `UnknownEntity`. After a gateway/catalog edit
6//! we upsert the route entities from the freshly-saved profile and re-apply the
7//! YAML grants, so the resolver reflects the edit without a restart or a wait
8//! for the boot-time governance pass.
9//!
10//! Reconciliation is best-effort: the profile write is the source of truth and
11//! has already succeeded. If the database is unreachable (an offline edit), we
12//! warn and return — the next app start reconciles the catalog.
13
14use std::path::Path;
15use std::sync::Arc;
16
17use systemprompt_database::{Database, DbPool};
18use systemprompt_identifiers::RouteId;
19use systemprompt_models::{Config, Profile};
20use systemprompt_security::authz::{
21    AccessControlIngestionService, AccessControlRepository, IngestOptions,
22    reconcile_gateway_entities,
23};
24use systemprompt_sync::AccessControlLocalSync;
25
26const ROLES_YAML_RELATIVE: &str = "access-control/roles.yaml";
27
28/// Result of a post-edit authz reconciliation. `Deferred` carries the reason
29/// the catalog could not be re-materialised now (e.g. the database was
30/// unreachable during an offline edit); the profile write has already succeeded
31/// regardless.
32pub(super) enum ReconcileOutcome {
33    Reconciled,
34    Deferred(String),
35}
36
37pub(super) async fn reconcile_authz(profile: &Profile, profile_path: &str) -> ReconcileOutcome {
38    match try_reconcile(profile, profile_path).await {
39        Ok(()) => ReconcileOutcome::Reconciled,
40        Err(err) => {
41            tracing::warn!(
42                error = %err,
43                "profile saved, but the authz catalog could not be reconciled now; it will be \
44                 reconciled on the next app start"
45            );
46            ReconcileOutcome::Deferred(err.to_string())
47        },
48    }
49}
50
51/// Append a visible deferral notice to a mutation's success message when the
52/// post-edit reconciliation could not run, so the operator sees that the live
53/// catalog is stale until the next app start (or a retry with the DB up).
54pub(super) fn append_reconcile_notice(message: String, outcome: &ReconcileOutcome) -> String {
55    match outcome {
56        ReconcileOutcome::Reconciled => message,
57        ReconcileOutcome::Deferred(reason) => format!(
58            "{message}\n\n⚠ authz reconcile deferred: {reason}\nThe profile was saved; the authz \
59             catalog will be reconciled on the next app start."
60        ),
61    }
62}
63
64async fn try_reconcile(profile: &Profile, profile_path: &str) -> anyhow::Result<()> {
65    let cfg = Config::get()?;
66    let database: DbPool = Arc::new(
67        Database::from_config_with_write(
68            &cfg.database_type,
69            &cfg.database_url,
70            cfg.database_write_url.as_deref(),
71        )
72        .await?,
73    );
74
75    let repo = AccessControlRepository::new(&database)?;
76    let route_ids = profile
77        .gateway
78        .as_ref()
79        .map(|gateway| gateway.dispatchable_route_ids(&profile.providers))
80        .unwrap_or_default();
81    let id_refs: Vec<&str> = route_ids.iter().map(RouteId::as_str).collect();
82    let source = format!("profile:{profile_path}");
83    reconcile_gateway_entities(&repo, &id_refs, &source).await?;
84
85    let roles_yaml = Path::new(&profile.paths.services).join(ROLES_YAML_RELATIVE);
86    if roles_yaml.exists() {
87        AccessControlLocalSync::new(Arc::clone(&database), roles_yaml)
88            .sync_to_db(true, false)
89            .await?;
90
91        let services = systemprompt_loader::ConfigLoader::load()?;
92        let svc = AccessControlIngestionService::new(&database)?;
93        svc.ingest_marketplace_access(
94            &services.marketplaces,
95            IngestOptions {
96                override_existing: true,
97                delete_orphans: false,
98            },
99        )
100        .await?;
101    }
102    Ok(())
103}