systemprompt_security/authz/
hook.rs1use 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]
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 detail: "no authz hook is configured; the default denies".to_owned(),
64 },
65 policy,
66 };
67 self.sink
68 .record(&req, &decision, AuthzSource::DenyAllDefault)
69 .await;
70 decision
71 }
72}
73
74#[derive(Debug, Clone)]
75pub struct AllowAllHook {
76 sink: Arc<dyn AuthzAuditSink>,
77}
78
79impl AllowAllHook {
80 pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
81 Self { sink }
82 }
83
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, detail: String) -> AuthzDecision {
133 let policy = AuthzSource::WebhookFault.policy().to_owned();
134 let decision = AuthzDecision::Deny {
135 reason: DenyReason::HookUnavailable {
136 policy: policy.clone(),
137 detail,
138 },
139 policy,
140 };
141 self.sink
142 .record(req, &decision, AuthzSource::WebhookFault)
143 .await;
144 decision
145 }
146}
147
148#[async_trait]
149impl AuthzDecisionHook for WebhookHook {
150 async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
151 let response = self.client.post(&self.url).json(&req).send().await;
152 let response = match response {
153 Ok(r) => r,
154 Err(err) => {
155 tracing::warn!(
156 error = %err,
157 url = %self.url,
158 "authz hook transport failure",
159 );
160 return self.fault(&req, format!("transport failure: {err}")).await;
161 },
162 };
163 if !response.status().is_success() {
164 tracing::warn!(
165 status = response.status().as_u16(),
166 url = %self.url,
167 "authz hook returned non-success status",
168 );
169 return self
170 .fault(
171 &req,
172 format!("hook returned status {}", response.status().as_u16()),
173 )
174 .await;
175 }
176 match response.json::<AuthzDecision>().await {
177 Ok(decision) => decision,
178 Err(err) => {
179 tracing::warn!(
180 error = %err,
181 url = %self.url,
182 "authz hook response decode failure",
183 );
184 self.fault(&req, format!("undecodable response: {err}"))
185 .await
186 },
187 }
188 }
189}