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