Skip to main content

systemprompt_security/authz/
runtime.rs

1//! Process-wide authz hook installed at server startup.
2//!
3//! Both the gateway `/v1/messages` middleware and the MCP RBAC middleware
4//! consult [`global_hook`] to retrieve the active hook. After
5//! [`install_from_governance_config`] runs the slot is always populated with
6//! one of [`DenyAllHook`], [`AllowAllHook`], or [`WebhookHook`] — there is no
7//! "uninstalled" path that callers can fall through.
8//!
9//! [`install_from_governance_config`] is the single source of truth for both
10//! the API server runtime and standalone MCP server binaries:
11//!
12//! - `mode: webhook` with a non-empty `url` → [`WebhookHook`] (fail-closed).
13//! - `mode: disabled`, or governance/authz absent → [`DenyAllHook`].
14//! - `mode: unrestricted` → [`AllowAllHook`], but ONLY when `acknowledgement`
15//!   exactly equals [`UNRESTRICTED_ACKNOWLEDGEMENT`]. Otherwise bootstrap
16//!   fails.
17//!
18//! Bootstrap ordering: this is called from `AppContextBuilder::build` after
19//! the database pool is created so the audit sink can write to
20//! `governance_decisions`.
21
22use std::sync::{Arc, OnceLock, RwLock};
23use std::time::Duration;
24
25use systemprompt_models::profile::{AuthzMode, GovernanceConfig, UNRESTRICTED_ACKNOWLEDGEMENT};
26
27use super::audit::{AuthzAuditSink, DbAuditSink, GovernanceDecisionRepository, NullAuditSink};
28use super::error::{AuthzBootstrapError, AuthzResult};
29use super::hook::{AllowAllHook, AuthzDecisionHook, DenyAllHook, WebhookHook};
30
31type SharedHook = Arc<dyn AuthzDecisionHook>;
32
33fn slot() -> &'static RwLock<Option<SharedHook>> {
34    static SLOT: OnceLock<RwLock<Option<SharedHook>>> = OnceLock::new();
35    SLOT.get_or_init(|| RwLock::new(None))
36}
37
38pub fn install_global_hook(hook: SharedHook) {
39    if let Ok(mut guard) = slot().write() {
40        *guard = Some(hook);
41    }
42}
43
44pub fn clear_global_hook() {
45    if let Ok(mut guard) = slot().write() {
46        *guard = None;
47    }
48}
49
50#[must_use]
51pub fn global_hook() -> Option<SharedHook> {
52    slot().read().ok().and_then(|g| g.clone())
53}
54
55pub fn install_from_governance_config(
56    governance: Option<&GovernanceConfig>,
57    pool: Option<Arc<sqlx::PgPool>>,
58) -> AuthzResult<()> {
59    let sink = build_sink(pool);
60
61    let Some(authz) = governance.and_then(|g| g.authz.as_ref()) else {
62        tracing::error!(
63            "governance.authz block missing — installing DenyAllHook (all requests will be denied)"
64        );
65        install_global_hook(Arc::new(DenyAllHook::new(sink)));
66        return Ok(());
67    };
68
69    match authz.hook.mode {
70        AuthzMode::Disabled => {
71            tracing::warn!("governance.authz.hook.mode = disabled — all requests will be denied");
72            install_global_hook(Arc::new(DenyAllHook::new(sink)));
73            Ok(())
74        },
75        AuthzMode::Unrestricted => {
76            let ack = authz.hook.acknowledgement.as_deref().map_or("", str::trim);
77            if ack != UNRESTRICTED_ACKNOWLEDGEMENT {
78                return Err(AuthzBootstrapError::MissingUnrestrictedAcknowledgement {
79                    expected: UNRESTRICTED_ACKNOWLEDGEMENT,
80                }
81                .into());
82            }
83            tracing::error!(
84                "governance.authz.hook.mode = unrestricted — ALL REQUESTS WILL BE ALLOWED. This \
85                 MUST NOT run in production."
86            );
87            install_global_hook(Arc::new(AllowAllHook::new(sink)));
88            Ok(())
89        },
90        AuthzMode::Webhook => {
91            let url = authz
92                .hook
93                .url
94                .as_deref()
95                .map(str::trim)
96                .filter(|s| !s.is_empty())
97                .ok_or(AuthzBootstrapError::MissingWebhookUrl)?
98                .to_owned();
99            let hook = WebhookHook::new(url, Duration::from_millis(authz.hook.timeout_ms), sink)?;
100            install_global_hook(Arc::new(hook));
101            Ok(())
102        },
103    }
104}
105
106fn build_sink(pool: Option<Arc<sqlx::PgPool>>) -> Arc<dyn AuthzAuditSink> {
107    pool.map_or_else(
108        || -> Arc<dyn AuthzAuditSink> { Arc::new(NullAuditSink) },
109        |p| -> Arc<dyn AuthzAuditSink> {
110            Arc::new(DbAuditSink::new(GovernanceDecisionRepository::from_pool(p)))
111        },
112    )
113}