stormchaser_model/
auth.rs1use crate::id::RunId;
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
7use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::sync::Arc;
11use tracing::debug;
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct Claims {
16 pub sub: String, pub email: Option<String>,
20 pub exp: usize, }
23
24#[async_trait]
26pub trait OpaWasmExecutor: Send + Sync {
27 async fn evaluate(&self, entrypoint: &str, input: &Value) -> Result<bool>;
29}
30
31#[derive(Clone)]
33pub struct OpaClient {
34 url: Option<String>,
35 http_client: ClientWithMiddleware,
36 wasm_executor: Option<Arc<dyn OpaWasmExecutor>>,
37 entrypoint: String,
38}
39
40impl std::fmt::Debug for OpaClient {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("OpaClient")
43 .field("url", &self.url)
44 .field("wasm_configured", &self.wasm_executor.is_some())
45 .field("entrypoint", &self.entrypoint)
46 .finish()
47 }
48}
49
50#[derive(Debug, Serialize)]
51struct OpaInput<T> {
52 input: T,
53}
54
55#[derive(Debug, Deserialize)]
56struct OpaResponse {
57 result: bool,
58}
59
60impl OpaClient {
61 pub fn new(url: Option<String>, tls_config: Option<Arc<rustls::ClientConfig>>) -> Self {
63 let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
64 let mut builder = reqwest::Client::builder();
65
66 if let Some(config) = tls_config {
67 builder = builder.use_preconfigured_tls(config);
68 }
69
70 let http_client =
71 ClientBuilder::new(builder.build().unwrap_or_else(|_| reqwest::Client::new()))
72 .with(RetryTransientMiddleware::new_with_policy(retry_policy))
73 .build();
74
75 Self {
76 url,
77 http_client,
78 wasm_executor: None,
79 entrypoint: "stormchaser/allow".to_string(),
80 }
81 }
82
83 pub fn with_wasm_executor(mut self, executor: Arc<dyn OpaWasmExecutor>) -> Self {
85 self.wasm_executor = Some(executor);
86 self
87 }
88
89 pub fn with_entrypoint(mut self, entrypoint: String) -> Self {
91 self.entrypoint = entrypoint;
92 self
93 }
94
95 pub fn is_configured(&self) -> bool {
97 self.url.is_some() || self.wasm_executor.is_some()
98 }
99
100 pub async fn check_context<T: Serialize>(&self, context: T) -> Result<bool> {
102 if let Some(executor) = &self.wasm_executor {
104 let context_val = serde_json::to_value(&context)?;
105 return executor.evaluate(&self.entrypoint, &context_val).await;
106 }
107
108 let url = match &self.url {
110 Some(url) => url,
111 None => return Ok(true), };
113
114 debug!("Checking OPA policy at {} with custom context", url);
115
116 let input = OpaInput { input: context };
117
118 let response = self
119 .http_client
120 .post(url)
121 .json(&input)
122 .send()
123 .await
124 .context("Failed to reach OPA server")?;
125
126 if !response.status().is_success() {
127 return Err(anyhow::anyhow!(
128 "OPA server returned error: {}",
129 response.status()
130 ));
131 }
132
133 let opa_res: OpaResponse = response
134 .json()
135 .await
136 .context("Failed to parse OPA response")?;
137
138 Ok(opa_res.result)
139 }
140}
141
142#[async_trait]
144pub trait OpaAuthorizer: Send + Sync {
145 async fn check(&self, context: ApiOpaContext<'_>) -> Result<bool>;
147 async fn check_approval(&self, context: ApprovalOpaContext<'_>) -> Result<bool>;
149 fn is_configured(&self) -> bool;
151}
152
153#[async_trait]
154impl OpaAuthorizer for OpaClient {
155 async fn check(&self, context: ApiOpaContext<'_>) -> Result<bool> {
156 self.check_context(context).await
157 }
158 async fn check_approval(&self, context: ApprovalOpaContext<'_>) -> Result<bool> {
159 self.check_context(context).await
160 }
161 fn is_configured(&self) -> bool {
162 self.is_configured()
163 }
164}
165
166#[derive(Debug, Serialize)]
168pub struct ApiOpaContext<'a> {
169 pub path: &'a str,
171 pub method: &'a str,
173 pub token: Option<&'a str>,
175}
176
177#[derive(Debug, Serialize)]
179pub struct EngineOpaContext {
180 pub run_id: RunId,
182 pub initiating_user: String,
184 pub workflow_ast: Value,
186 pub inputs: Value,
188}
189
190#[derive(Debug, Serialize)]
192pub struct ApprovalOpaContext<'a> {
193 pub run_id: RunId,
195 pub initiating_user: String,
197 pub step_ast: Value,
199 pub inputs: Value,
201 pub run_outputs: Value,
203 pub token: Option<&'a str>,
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_opa_client_config() {
213 let client = OpaClient::new(None, None);
214 assert!(!client.is_configured());
215 assert!(!OpaAuthorizer::is_configured(&client));
216
217 let client = OpaClient::new(Some("http://localhost:8181".to_string()), None);
218 assert!(client.is_configured());
219 assert_eq!(client.entrypoint, "stormchaser/allow");
220
221 let client = client.with_entrypoint("custom/allow".to_string());
222 assert_eq!(client.entrypoint, "custom/allow");
223 }
224
225 #[test]
226 fn test_opa_client_debug() {
227 let client = OpaClient::new(Some("http://localhost:8181".to_string()), None);
228 let debug_str = format!("{:?}", client);
229 assert!(debug_str.contains("OpaClient"));
230 assert!(debug_str.contains("url: Some(\"http://localhost:8181\")"));
231 assert!(debug_str.contains("wasm_configured: false"));
232 }
233}