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    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    pub fn null() -> Self {
81        Self {
82            sink: Arc::new(NullAuditSink),
83        }
84    }
85}
86
87#[async_trait]
88impl AuthzDecisionHook for AllowAllHook {
89    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
90        let decision = AuthzDecision::Allow;
91        self.sink
92            .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
93            .await;
94        decision
95    }
96}
97
98#[derive(Debug, Clone)]
99pub struct WebhookHook {
100    url: String,
101    timeout: Duration,
102    client: reqwest::Client,
103    sink: Arc<dyn AuthzAuditSink>,
104}
105
106impl WebhookHook {
107    pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
108        let client = reqwest::Client::builder().timeout(timeout).build()?;
109        Ok(Self {
110            url,
111            timeout,
112            client,
113            sink,
114        })
115    }
116
117    pub fn url(&self) -> &str {
118        &self.url
119    }
120
121    pub const fn timeout(&self) -> Duration {
122        self.timeout
123    }
124
125    async fn fault(&self, req: &AuthzRequest) -> AuthzDecision {
126        let policy = AuthzSource::WebhookFault.policy().to_owned();
127        let decision = AuthzDecision::Deny {
128            reason: DenyReason::HookUnavailable {
129                policy: policy.clone(),
130            },
131            policy,
132        };
133        self.sink
134            .record(req, &decision, AuthzSource::WebhookFault)
135            .await;
136        decision
137    }
138}
139
140#[async_trait]
141impl AuthzDecisionHook for WebhookHook {
142    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
143        let response = self.client.post(&self.url).json(&req).send().await;
144        let response = match response {
145            Ok(r) => r,
146            Err(err) => {
147                tracing::warn!(
148                    error = %err,
149                    url = %self.url,
150                    "authz hook transport failure",
151                );
152                return self.fault(&req).await;
153            },
154        };
155        if !response.status().is_success() {
156            tracing::warn!(
157                status = response.status().as_u16(),
158                url = %self.url,
159                "authz hook returned non-success status",
160            );
161            return self.fault(&req).await;
162        }
163        match response.json::<AuthzDecision>().await {
164            Ok(decision) => decision,
165            Err(err) => {
166                tracing::warn!(
167                    error = %err,
168                    url = %self.url,
169                    "authz hook response decode failure",
170                );
171                self.fault(&req).await
172            },
173        }
174    }
175}