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