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