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;
24
25use super::audit::{AuthzAuditSink, AuthzSource, NullAuditSink};
26use super::error::AuthzResult;
27use super::types::{AuthzDecision, AuthzRequest, DenyReason};
28
29/// `#[async_trait]`: this trait is consumed as `Arc<dyn AuthzDecisionHook>`
30/// (see `authz::runtime`), so it must be `dyn`-compatible — native
31/// `async fn` in traits is not yet object-safe.
32#[async_trait]
33pub trait AuthzDecisionHook: Send + Sync + std::fmt::Debug {
34    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision;
35}
36
37pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
38
39#[derive(Debug, Clone)]
40pub struct DenyAllHook {
41    sink: Arc<dyn AuthzAuditSink>,
42}
43
44impl DenyAllHook {
45    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
46        Self { sink }
47    }
48
49    pub fn null() -> Self {
50        Self {
51            sink: Arc::new(NullAuditSink),
52        }
53    }
54}
55
56#[async_trait]
57impl AuthzDecisionHook for DenyAllHook {
58    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
59        let policy = AuthzSource::DenyAllDefault.policy().to_owned();
60        let decision = AuthzDecision::Deny {
61            reason: DenyReason::HookUnavailable {
62                policy: policy.clone(),
63                detail: "no authz hook is configured; the default denies".to_owned(),
64            },
65            policy,
66        };
67        self.sink
68            .record(&req, &decision, AuthzSource::DenyAllDefault)
69            .await;
70        decision
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct AllowAllHook {
76    sink: Arc<dyn AuthzAuditSink>,
77}
78
79impl AllowAllHook {
80    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
81        Self { sink }
82    }
83
84    pub fn null() -> Self {
85        Self {
86            sink: Arc::new(NullAuditSink),
87        }
88    }
89}
90
91#[async_trait]
92impl AuthzDecisionHook for AllowAllHook {
93    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
94        let decision = AuthzDecision::Allow;
95        self.sink
96            .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
97            .await;
98        decision
99    }
100}
101
102#[derive(Debug, Clone)]
103pub struct WebhookHook {
104    url: String,
105    timeout: Duration,
106    client: reqwest::Client,
107    sink: Arc<dyn AuthzAuditSink>,
108}
109
110impl WebhookHook {
111    pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
112        let client = reqwest::Client::builder().timeout(timeout).build()?;
113        Ok(Self {
114            url,
115            timeout,
116            client,
117            sink,
118        })
119    }
120
121    pub fn url(&self) -> &str {
122        &self.url
123    }
124
125    pub const fn timeout(&self) -> Duration {
126        self.timeout
127    }
128
129    // Why: `detail` reaches the audit row, so a hook that is unreachable, one
130    // that answers 500 and one that returns undecodable JSON are three
131    // different rows rather than one indistinguishable "unavailable".
132    async fn fault(&self, req: &AuthzRequest, detail: String) -> AuthzDecision {
133        let policy = AuthzSource::WebhookFault.policy().to_owned();
134        let decision = AuthzDecision::Deny {
135            reason: DenyReason::HookUnavailable {
136                policy: policy.clone(),
137                detail,
138            },
139            policy,
140        };
141        self.sink
142            .record(req, &decision, AuthzSource::WebhookFault)
143            .await;
144        decision
145    }
146}
147
148#[async_trait]
149impl AuthzDecisionHook for WebhookHook {
150    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
151        let response = self.client.post(&self.url).json(&req).send().await;
152        let response = match response {
153            Ok(r) => r,
154            Err(err) => {
155                tracing::warn!(
156                    error = %err,
157                    url = %self.url,
158                    "authz hook transport failure",
159                );
160                return self.fault(&req, format!("transport failure: {err}")).await;
161            },
162        };
163        if !response.status().is_success() {
164            tracing::warn!(
165                status = response.status().as_u16(),
166                url = %self.url,
167                "authz hook returned non-success status",
168            );
169            return self
170                .fault(
171                    &req,
172                    format!("hook returned status {}", response.status().as_u16()),
173                )
174                .await;
175        }
176        match response.json::<AuthzDecision>().await {
177            Ok(decision) => decision,
178            Err(err) => {
179                tracing::warn!(
180                    error = %err,
181                    url = %self.url,
182                    "authz hook response decode failure",
183                );
184                self.fault(&req, format!("undecodable response: {err}"))
185                    .await
186            },
187        }
188    }
189}