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//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24
25use super::audit::{AuthzAuditSink, AuthzSource, NullAuditSink};
26use super::error::AuthzResult;
27use super::types::{AuthzDecision, AuthzRequest, DenyReason};
28
29/// `#[async_trait]`: this trait is consumed as `Arc<dyn AuthzDecisionHook>`
30/// (see `authz::runtime`), so it must be `dyn`-compatible — native
31/// `async fn` in traits is not yet object-safe.
32#[async_trait]
33pub trait AuthzDecisionHook: Send + Sync + std::fmt::Debug {
34    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision;
35}
36
37pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
38
39#[derive(Debug, Clone)]
40pub struct DenyAllHook {
41    sink: Arc<dyn AuthzAuditSink>,
42}
43
44impl DenyAllHook {
45    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
46        Self { sink }
47    }
48
49    pub fn null() -> Self {
50        Self {
51            sink: Arc::new(NullAuditSink),
52        }
53    }
54}
55
56#[async_trait]
57impl AuthzDecisionHook for DenyAllHook {
58    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
59        let policy = AuthzSource::DenyAllDefault.policy().to_owned();
60        let decision = AuthzDecision::Deny {
61            reason: DenyReason::HookUnavailable {
62                policy: policy.clone(),
63            },
64            policy,
65        };
66        self.sink
67            .record(&req, &decision, AuthzSource::DenyAllDefault)
68            .await;
69        decision
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct AllowAllHook {
75    sink: Arc<dyn AuthzAuditSink>,
76}
77
78impl AllowAllHook {
79    pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
80        Self { sink }
81    }
82
83    pub fn null() -> Self {
84        Self {
85            sink: Arc::new(NullAuditSink),
86        }
87    }
88}
89
90#[async_trait]
91impl AuthzDecisionHook for AllowAllHook {
92    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
93        let decision = AuthzDecision::Allow;
94        self.sink
95            .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
96            .await;
97        decision
98    }
99}
100
101#[derive(Debug, Clone)]
102pub struct WebhookHook {
103    url: String,
104    timeout: Duration,
105    client: reqwest::Client,
106    sink: Arc<dyn AuthzAuditSink>,
107}
108
109impl WebhookHook {
110    pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
111        let client = reqwest::Client::builder().timeout(timeout).build()?;
112        Ok(Self {
113            url,
114            timeout,
115            client,
116            sink,
117        })
118    }
119
120    pub fn url(&self) -> &str {
121        &self.url
122    }
123
124    pub const fn timeout(&self) -> Duration {
125        self.timeout
126    }
127
128    async fn fault(&self, req: &AuthzRequest) -> AuthzDecision {
129        let policy = AuthzSource::WebhookFault.policy().to_owned();
130        let decision = AuthzDecision::Deny {
131            reason: DenyReason::HookUnavailable {
132                policy: policy.clone(),
133            },
134            policy,
135        };
136        self.sink
137            .record(req, &decision, AuthzSource::WebhookFault)
138            .await;
139        decision
140    }
141}
142
143#[async_trait]
144impl AuthzDecisionHook for WebhookHook {
145    async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
146        let response = self.client.post(&self.url).json(&req).send().await;
147        let response = match response {
148            Ok(r) => r,
149            Err(err) => {
150                tracing::warn!(
151                    error = %err,
152                    url = %self.url,
153                    "authz hook transport failure",
154                );
155                return self.fault(&req).await;
156            },
157        };
158        if !response.status().is_success() {
159            tracing::warn!(
160                status = response.status().as_u16(),
161                url = %self.url,
162                "authz hook returned non-success status",
163            );
164            return self.fault(&req).await;
165        }
166        match response.json::<AuthzDecision>().await {
167            Ok(decision) => decision,
168            Err(err) => {
169                tracing::warn!(
170                    error = %err,
171                    url = %self.url,
172                    "authz hook response decode failure",
173                );
174                self.fault(&req).await
175            },
176        }
177    }
178}