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
34pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
35
36#[derive(Debug, Clone)]
37pub struct DenyAllHook {
38    sink: Arc<dyn AuthzAuditSink>,
39}
40
41impl DenyAllHook {
42    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
43        Self { sink }
44    }
45
46    // Tests and pre-database bootstrap. Production paths should always pass a
47    // real sink so denies during outages stay observable.
48    pub fn null() -> Self {
49        Self {
50            sink: Arc::new(NullAuditSink),
51        }
52    }
53}
54
55#[async_trait]
56impl AuthzDecisionHook for DenyAllHook {
57    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
58        let policy = AuthzSource::DenyAllDefault.policy().to_owned();
59        let decision = AuthzDecision::Deny {
60            reason: DenyReason::HookUnavailable {
61                policy: policy.clone(),
62            },
63            policy,
64        };
65        self.sink
66            .record(&req, &decision, AuthzSource::DenyAllDefault)
67            .await;
68        decision
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct AllowAllHook {
74    sink: Arc<dyn AuthzAuditSink>,
75}
76
77impl AllowAllHook {
78    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
79        Self { sink }
80    }
81
82    // Tests only — production installs go through the explicit unrestricted
83    // opt-in path which always wires a real sink.
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    async fn fault(&self, req: &AuthzRequest) -> AuthzDecision {
130        let policy = AuthzSource::WebhookFault.policy().to_owned();
131        let decision = AuthzDecision::Deny {
132            reason: DenyReason::HookUnavailable {
133                policy: policy.clone(),
134            },
135            policy,
136        };
137        self.sink
138            .record(req, &decision, AuthzSource::WebhookFault)
139            .await;
140        decision
141    }
142}
143
144#[async_trait]
145impl AuthzDecisionHook for WebhookHook {
146    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
147        let response = self.client.post(&self.url).json(&req).send().await;
148        let response = match response {
149            Ok(r) => r,
150            Err(err) => {
151                tracing::warn!(
152                    error = %err,
153                    url = %self.url,
154                    "authz hook transport failure",
155                );
156                return self.fault(&req).await;
157            },
158        };
159        if !response.status().is_success() {
160            tracing::warn!(
161                status = response.status().as_u16(),
162                url = %self.url,
163                "authz hook returned non-success status",
164            );
165            return self.fault(&req).await;
166        }
167        match response.json::<AuthzDecision>().await {
168            Ok(decision) => decision,
169            Err(err) => {
170                tracing::warn!(
171                    error = %err,
172                    url = %self.url,
173                    "authz hook response decode failure",
174                );
175                self.fault(&req).await
176            },
177        }
178    }
179}