Skip to main content

systemprompt_security/authz/
runtime.rs

1//! Construction of the active authz decision hook from profile config.
2//!
3//! [`build_authz_hook`] is the single entry point for both the API server and
4//! any standalone MCP binary. It inspects `governance.authz` and returns one
5//! of [`DenyAllHook`], [`AllowAllHook`], or [`WebhookHook`], wrapped in an
6//! `Arc<dyn AuthzDecisionHook>` that the caller stores on its `AppContext`
7//! (or equivalent) and threads to every consumer.
8//!
9//! Branch table:
10//!
11//! - `mode: webhook` with a non-empty `url` that passes SSRF validation →
12//!   [`WebhookHook`] (fail-closed). A url pointing at loopback over `http`,
13//!   `169.254.169.254`, or an RFC1918 range fails bootstrap.
14//! - `mode: disabled`, or governance/authz absent → [`DenyAllHook`].
15//! - `mode: unrestricted` → [`AllowAllHook`], but ONLY when `acknowledgement`
16//!   exactly equals [`UNRESTRICTED_ACKNOWLEDGEMENT`]. Otherwise bootstrap
17//!   fails. An error-level warning is always logged; refusing this mode in
18//!   production is the operator's responsibility.
19//!
20//! Bootstrap ordering: called from `AppContextBuilder::build` after the
21//! database pool is created so the audit sink can write to
22//! `governance_decisions`.
23
24use std::sync::Arc;
25use std::time::Duration;
26
27use systemprompt_models::net::validate_outbound_url;
28use systemprompt_models::profile::{AuthzMode, GovernanceConfig, UNRESTRICTED_ACKNOWLEDGEMENT};
29
30use super::audit::{AuthzAuditSink, DbAuditSink, GovernanceDecisionRepository, NullAuditSink};
31use super::error::{AuthzBootstrapError, AuthzResult};
32use super::hook::{AllowAllHook, AuthzDecisionHook, DenyAllHook, WebhookHook};
33
34pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
35
36pub fn build_authz_hook(
37    governance: Option<&GovernanceConfig>,
38    pool: Option<Arc<sqlx::PgPool>>,
39) -> AuthzResult<SharedAuthzHook> {
40    let sink = build_sink(pool);
41
42    let Some(authz) = governance.and_then(|g| g.authz.as_ref()) else {
43        tracing::error!(
44            "governance.authz block missing — using DenyAllHook (all requests will be denied)"
45        );
46        return Ok(Arc::new(DenyAllHook::new(sink)));
47    };
48
49    match authz.hook.mode {
50        AuthzMode::Disabled => {
51            tracing::warn!("governance.authz.hook.mode = disabled — all requests will be denied");
52            Ok(Arc::new(DenyAllHook::new(sink)))
53        },
54        AuthzMode::Unrestricted => {
55            let ack = authz.hook.acknowledgement.as_deref().map_or("", str::trim);
56            if ack != UNRESTRICTED_ACKNOWLEDGEMENT {
57                return Err(AuthzBootstrapError::MissingUnrestrictedAcknowledgement {
58                    expected: UNRESTRICTED_ACKNOWLEDGEMENT,
59                }
60                .into());
61            }
62            tracing::error!(
63                "governance.authz.hook.mode = unrestricted — ALL REQUESTS WILL BE ALLOWED. This \
64                 MUST NOT run in production."
65            );
66            Ok(Arc::new(AllowAllHook::new(sink)))
67        },
68        AuthzMode::Webhook => {
69            let url = authz
70                .hook
71                .url
72                .as_deref()
73                .map(str::trim)
74                .filter(|s| !s.is_empty())
75                .ok_or(AuthzBootstrapError::MissingWebhookUrl)?
76                .to_owned();
77            validate_outbound_url(&url)
78                .map_err(|e| AuthzBootstrapError::InvalidWebhookUrl(e.to_string()))?;
79            let hook = WebhookHook::new(url, Duration::from_millis(authz.hook.timeout_ms), sink)?;
80            Ok(Arc::new(hook))
81        },
82    }
83}
84
85fn build_sink(pool: Option<Arc<sqlx::PgPool>>) -> Arc<dyn AuthzAuditSink> {
86    pool.map_or_else(
87        || -> Arc<dyn AuthzAuditSink> { Arc::new(NullAuditSink) },
88        |p| -> Arc<dyn AuthzAuditSink> {
89            Arc::new(DbAuditSink::new(GovernanceDecisionRepository::from_pool(p)))
90        },
91    )
92}