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]`: 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 decision = AuthzDecision::Deny {
57            reason: "no authz hook configured".into(),
58            policy: AuthzSource::DenyAllDefault.policy().to_string(),
59        };
60        self.sink
61            .record(&req, &decision, AuthzSource::DenyAllDefault)
62            .await;
63        decision
64    }
65}
66
67#[derive(Debug, Clone)]
68pub struct AllowAllHook {
69    sink: Arc<dyn AuthzAuditSink>,
70}
71
72impl AllowAllHook {
73    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
74        Self { sink }
75    }
76
77    // Tests only — production installs go through the explicit unrestricted
78    // opt-in path which always wires a real sink.
79    pub fn null() -> Self {
80        Self {
81            sink: Arc::new(NullAuditSink),
82        }
83    }
84}
85
86#[async_trait]
87impl AuthzDecisionHook for AllowAllHook {
88    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
89        let decision = AuthzDecision::Allow;
90        self.sink
91            .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
92            .await;
93        decision
94    }
95}
96
97#[derive(Debug, Clone)]
98pub struct WebhookHook {
99    url: String,
100    timeout: Duration,
101    client: reqwest::Client,
102    sink: Arc<dyn AuthzAuditSink>,
103}
104
105impl WebhookHook {
106    pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
107        let client = reqwest::Client::builder().timeout(timeout).build()?;
108        Ok(Self {
109            url,
110            timeout,
111            client,
112            sink,
113        })
114    }
115
116    pub fn url(&self) -> &str {
117        &self.url
118    }
119
120    pub const fn timeout(&self) -> Duration {
121        self.timeout
122    }
123
124    async fn fault(&self, req: &AuthzRequest) -> AuthzDecision {
125        let decision = AuthzDecision::Deny {
126            reason: "authz hook unreachable".into(),
127            policy: AuthzSource::WebhookFault.policy().to_string(),
128        };
129        self.sink
130            .record(req, &decision, AuthzSource::WebhookFault)
131            .await;
132        decision
133    }
134}
135
136#[async_trait]
137impl AuthzDecisionHook for WebhookHook {
138    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
139        let response = self.client.post(&self.url).json(&req).send().await;
140        let response = match response {
141            Ok(r) => r,
142            Err(err) => {
143                tracing::warn!(
144                    error = %err,
145                    url = %self.url,
146                    "authz hook transport failure",
147                );
148                return self.fault(&req).await;
149            },
150        };
151        if !response.status().is_success() {
152            tracing::warn!(
153                status = response.status().as_u16(),
154                url = %self.url,
155                "authz hook returned non-success status",
156            );
157            return self.fault(&req).await;
158        }
159        match response.json::<AuthzDecision>().await {
160            Ok(decision) => decision,
161            Err(err) => {
162                tracing::warn!(
163                    error = %err,
164                    url = %self.url,
165                    "authz hook response decode failure",
166                );
167                self.fault(&req).await
168            },
169        }
170    }
171}