systemprompt_security/authz/
hook.rs1use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use systemprompt_client::{GuardedClientConfig, guarded_client};
25
26use super::audit::{AuthzAuditSink, AuthzSource, NullAuditSink};
27use super::error::AuthzResult;
28use super::types::{AuthzDecision, AuthzRequest, DenyReason};
29
30#[async_trait]
34pub trait AuthzDecisionHook: Send + Sync + std::fmt::Debug {
35 async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision;
36}
37
38pub type SharedAuthzHook = Arc<dyn AuthzDecisionHook>;
39
40#[derive(Debug, Clone)]
41pub struct DenyAllHook {
42 sink: Arc<dyn AuthzAuditSink>,
43}
44
45impl DenyAllHook {
46 pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
47 Self { sink }
48 }
49
50 pub fn null() -> Self {
51 Self {
52 sink: Arc::new(NullAuditSink),
53 }
54 }
55}
56
57#[async_trait]
58impl AuthzDecisionHook for DenyAllHook {
59 async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
60 let policy = AuthzSource::DenyAllDefault.policy().to_owned();
61 let decision = AuthzDecision::Deny {
62 reason: DenyReason::HookUnavailable {
63 policy: policy.clone(),
64 detail: "no authz hook is configured; the default denies".to_owned(),
65 },
66 policy,
67 };
68 self.sink
69 .record(&req, &decision, AuthzSource::DenyAllDefault)
70 .await;
71 decision
72 }
73}
74
75#[derive(Debug, Clone)]
76pub struct AllowAllHook {
77 sink: Arc<dyn AuthzAuditSink>,
78}
79
80impl AllowAllHook {
81 pub fn new(sink: Arc<dyn AuthzAuditSink>) -> Self {
82 Self { sink }
83 }
84
85 pub fn null() -> Self {
86 Self {
87 sink: Arc::new(NullAuditSink),
88 }
89 }
90}
91
92#[async_trait]
93impl AuthzDecisionHook for AllowAllHook {
94 async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
95 let decision = AuthzDecision::Allow;
96 self.sink
97 .record(&req, &decision, AuthzSource::AllowAllUnrestricted)
98 .await;
99 decision
100 }
101}
102
103#[derive(Debug, Clone)]
104pub struct WebhookHook {
105 url: String,
106 timeout: Duration,
107 client: reqwest::Client,
108 sink: Arc<dyn AuthzAuditSink>,
109}
110
111impl WebhookHook {
112 pub fn new(url: String, timeout: Duration, sink: Arc<dyn AuthzAuditSink>) -> AuthzResult<Self> {
113 let client = guarded_client(&GuardedClientConfig::default().with_timeout(timeout))?;
114 Ok(Self {
115 url,
116 timeout,
117 client,
118 sink,
119 })
120 }
121
122 pub fn url(&self) -> &str {
123 &self.url
124 }
125
126 pub const fn timeout(&self) -> Duration {
127 self.timeout
128 }
129
130 async fn fault(&self, req: &AuthzRequest, detail: String) -> AuthzDecision {
131 let policy = AuthzSource::WebhookFault.policy().to_owned();
132 let decision = AuthzDecision::Deny {
133 reason: DenyReason::HookUnavailable {
134 policy: policy.clone(),
135 detail,
136 },
137 policy,
138 };
139 self.sink
140 .record(req, &decision, AuthzSource::WebhookFault)
141 .await;
142 decision
143 }
144}
145
146#[async_trait]
147impl AuthzDecisionHook for WebhookHook {
148 async fn evaluate(&self, req: AuthzRequest) -> AuthzDecision {
149 let response = self.client.post(&self.url).json(&req).send().await;
150 let response = match response {
151 Ok(r) => r,
152 Err(err) => {
153 tracing::warn!(
154 error = %err,
155 url = %self.url,
156 "authz hook transport failure",
157 );
158 return self.fault(&req, format!("transport failure: {err}")).await;
159 },
160 };
161 if !response.status().is_success() {
162 tracing::warn!(
163 status = response.status().as_u16(),
164 url = %self.url,
165 "authz hook returned non-success status",
166 );
167 return self
168 .fault(
169 &req,
170 format!("hook returned status {}", response.status().as_u16()),
171 )
172 .await;
173 }
174 match response.json::<AuthzDecision>().await {
175 Ok(decision) => decision,
176 Err(err) => {
177 tracing::warn!(
178 error = %err,
179 url = %self.url,
180 "authz hook response decode failure",
181 );
182 self.fault(&req, format!("undecodable response: {err}"))
183 .await
184 },
185 }
186 }
187}