stormchaser_model/
auth.rs1use anyhow::{Context, Result};
4use async_trait::async_trait;
5use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
6use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::sync::Arc;
10use tracing::debug;
11use uuid::Uuid;
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 fn is_configured(&self) -> bool;
149}
150
151#[async_trait]
152impl OpaAuthorizer for OpaClient {
153 async fn check(&self, context: ApiOpaContext<'_>) -> Result<bool> {
154 self.check_context(context).await
155 }
156 fn is_configured(&self) -> bool {
157 self.is_configured()
158 }
159}
160
161#[derive(Debug, Serialize)]
163pub struct ApiOpaContext<'a> {
164 pub path: &'a str,
166 pub method: &'a str,
168 pub token: Option<&'a str>,
170}
171
172#[derive(Debug, Serialize)]
174pub struct EngineOpaContext {
175 pub run_id: Uuid,
177 pub initiating_user: String,
179 pub workflow_ast: Value,
181 pub inputs: Value,
183}