Skip to main content

systemprompt_security/authz/
hook.rs

1//! Authorization decision hooks.
2//!
3//! Core fires [`AuthzDecisionHook::evaluate`] from the gateway and MCP
4//! enforcement sites. Three implementations:
5//!
6//! - [`WebhookHook`] — production. POSTs to an extension HTTP handler (e.g. the
7//!   template's `POST /govern/authz`). Any transport error, non-2xx, decode
8//!   failure, or timeout **denies** the request and records the fault to the
9//!   audit sink. There is no fail-open mode.
10//! - [`DenyAllHook`] — bootstrap default and `mode: disabled`. Denies every
11//!   request and records to the audit sink so outages remain observable.
12//! - [`AllowAllHook`] — TEST/DEV ONLY. Installed only when the operator passes
13//!   the explicit `unrestricted` acknowledgement in the profile. Allows every
14//!   request; logs an `ERROR` line at boot and writes an audit row per call so
15//!   unrestricted operation is never silent.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use systemprompt_client::{GuardedClientConfig, guarded_client};
25
26use super::audit::{AuthzAuditSink, AuthzSource, NullAuditSink};
27use super::error::AuthzResult;
28use super::types::{AuthzDecision, AuthzRequest, DenyReason};
29
30/// `#[async_trait]`: this trait is consumed as `Arc<dyn AuthzDecisionHook>`
31/// (see `authz::runtime`), so it must be `dyn`-compatible — native
32/// `async fn` in traits is not yet object-safe.
33#[async_trait]
34pub trait AuthzDecisionHook: Send + Sync + std::fmt::Debug {
35    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision;
36}
37
38pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
39
40#[derive(Debug, Clone)]
41pub struct DenyAllHook {
42    sink: Arc<dyn AuthzAuditSink>,
43}
44
45impl DenyAllHook {
46    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
47        Self { sink }
48    }
49
50    pub fn null() -> Self {
51        Self {
52            sink: Arc::new(NullAuditSink),
53        }
54    }
55}
56
57#[async_trait]
58impl AuthzDecisionHook for DenyAllHook {
59    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
60        let policy = AuthzSource::DenyAllDefault.policy().to_owned();
61        let decision = AuthzDecision::Deny {
62            reason: DenyReason::HookUnavailable {
63                policy: policy.clone(),
64                detail: "no authz hook is configured; the default denies".to_owned(),
65            },
66            policy,
67        };
68        self.sink
69            .record(&req, &decision, AuthzSource::DenyAllDefault)
70            .await;
71        decision
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct AllowAllHook {
77    sink: Arc<dyn AuthzAuditSink>,
78}
79
80impl AllowAllHook {
81    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
82        Self { sink }
83    }
84
85    pub fn null() -> Self {
86        Self {
87            sink: Arc::new(NullAuditSink),
88        }
89    }
90}
91
92#[async_trait]
93impl AuthzDecisionHook for AllowAllHook {
94    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
95        let decision = AuthzDecision::Allow;
96        self.sink
97            .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
98            .await;
99        decision
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct WebhookHook {
105    url: String,
106    timeout: Duration,
107    client: reqwest::Client,
108    sink: Arc<dyn AuthzAuditSink>,
109}
110
111impl WebhookHook {
112    pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
113        let client = guarded_client(&GuardedClientConfig::default().with_timeout(timeout))?;
114        Ok(Self {
115            url,
116            timeout,
117            client,
118            sink,
119        })
120    }
121
122    pub fn url(&self) -> &str {
123        &self.url
124    }
125
126    pub const fn timeout(&self) -> Duration {
127        self.timeout
128    }
129
130    async fn fault(&self, req: &AuthzRequest, detail: String) -> AuthzDecision {
131        let policy = AuthzSource::WebhookFault.policy().to_owned();
132        let decision = AuthzDecision::Deny {
133            reason: DenyReason::HookUnavailable {
134                policy: policy.clone(),
135                detail,
136            },
137            policy,
138        };
139        self.sink
140            .record(req, &decision, AuthzSource::WebhookFault)
141            .await;
142        decision
143    }
144}
145
146#[async_trait]
147impl AuthzDecisionHook for WebhookHook {
148    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
149        let response = self.client.post(&self.url).json(&req).send().await;
150        let response = match response {
151            Ok(r) => r,
152            Err(err) => {
153                tracing::warn!(
154                    error = %err,
155                    url = %self.url,
156                    "authz hook transport failure",
157                );
158                return self.fault(&req, format!("transport failure: {err}")).await;
159            },
160        };
161        if !response.status().is_success() {
162            tracing::warn!(
163                status = response.status().as_u16(),
164                url = %self.url,
165                "authz hook returned non-success status",
166            );
167            return self
168                .fault(
169                    &req,
170                    format!("hook returned status {}", response.status().as_u16()),
171                )
172                .await;
173        }
174        match response.json::<AuthzDecision>().await {
175            Ok(decision) => decision,
176            Err(err) => {
177                tracing::warn!(
178                    error = %err,
179                    url = %self.url,
180                    "authz hook response decode failure",
181                );
182                self.fault(&req, format!("undecodable response: {err}"))
183                    .await
184            },
185        }
186    }
187}