Skip to main content

stormchaser_model/
auth.rs

1//! Authentication and authorization models and OPA client.
2
3use 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/// Extracted claims from a JWT token.
14#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct Claims {
16    /// Subject (User ID) of the token.
17    pub sub: String, // User ID
18    /// Optional email address of the user.
19    pub email: Option<String>,
20    /// Expiration time as a Unix timestamp.
21    pub exp: usize, // Expiration time
22}
23
24/// Trait for executing Open Policy Agent (OPA) policies compiled to WebAssembly.
25#[async_trait]
26pub trait OpaWasmExecutor: Send + Sync {
27    /// Evaluates a WASM-compiled OPA policy against the given input.
28    async fn evaluate(&self, entrypoint: &str, input: &Value) -> Result<bool>;
29}
30
31/// Client for interacting with an Open Policy Agent (OPA) server or WASM module.
32#[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    /// Creates a new OpaClient with the given URL and TLS configuration.
62    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    /// Configures the client to use a WASM executor.
84    pub fn with_wasm_executor(mut self, executor: Arc<dyn OpaWasmExecutor>) -> Self {
85        self.wasm_executor = Some(executor);
86        self
87    }
88
89    /// Sets the entrypoint for the OPA policy.
90    pub fn with_entrypoint(mut self, entrypoint: String) -> Self {
91        self.entrypoint = entrypoint;
92        self
93    }
94
95    /// Returns true if the client is configured with either a URL or a WASM executor.
96    pub fn is_configured(&self) -> bool {
97        self.url.is_some() || self.wasm_executor.is_some()
98    }
99
100    /// Flexible check that sends any serializable context to OPA
101    pub async fn check_context<T: Serialize>(&self, context: T) -> Result<bool> {
102        // 1. Try WASM executor first if configured
103        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        // 2. Fallback to HTTP OPA if configured
109        let url = match &self.url {
110            Some(url) => url,
111            None => return Ok(true), // No-op if not configured
112        };
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/// Trait for authorizing requests using an Open Policy Agent (OPA).
143#[async_trait]
144pub trait OpaAuthorizer: Send + Sync {
145    /// Checks the given context against the OPA policy.
146    async fn check(&self, context: ApiOpaContext<'_>) -> Result<bool>;
147    /// Checks an approval context against the OPA policy.
148    async fn check_approval(&self, context: ApprovalOpaContext<'_>) -> Result<bool>;
149    /// Returns true if the authorizer is properly configured.
150    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/// Context for OPA checks in the API
167#[derive(Debug, Serialize)]
168pub struct ApiOpaContext<'a> {
169    /// The requested path.
170    pub path: &'a str,
171    /// The HTTP method.
172    pub method: &'a str,
173    /// The optional authentication token.
174    pub token: Option<&'a str>,
175}
176
177/// Context for OPA checks in the Engine after DSL parsing
178#[derive(Debug, Serialize)]
179pub struct EngineOpaContext {
180    /// Associated workflow run ID.
181    pub run_id: RunId,
182    /// Identifier of the user who initiated the run.
183    pub initiating_user: String,
184    /// Full parsed abstract syntax tree of the workflow.
185    pub workflow_ast: Value,
186    /// JSON inputs for the workflow run.
187    pub inputs: Value,
188}
189
190/// Context for OPA checks during HITL Approvals
191#[derive(Debug, Serialize)]
192pub struct ApprovalOpaContext<'a> {
193    /// Associated workflow run ID.
194    pub run_id: RunId,
195    /// Identifier of the user who initiated the run.
196    pub initiating_user: String,
197    /// The parsed abstract syntax tree of the approval step.
198    pub step_ast: Value,
199    /// JSON inputs for the workflow run.
200    pub inputs: Value,
201    /// All outputs generated by previous steps in the run.
202    pub run_outputs: Value,
203    /// The optional authentication token of the approver.
204    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}