Skip to main content

stormchaser_model/
auth.rs

1//! Authentication and authorization models and OPA client.
2
3use 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/// 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    /// Returns true if the authorizer is properly configured.
148    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/// Context for OPA checks in the API
162#[derive(Debug, Serialize)]
163pub struct ApiOpaContext<'a> {
164    /// The requested path.
165    pub path: &'a str,
166    /// The HTTP method.
167    pub method: &'a str,
168    /// The optional authentication token.
169    pub token: Option<&'a str>,
170}
171
172/// Context for OPA checks in the Engine after DSL parsing
173#[derive(Debug, Serialize)]
174pub struct EngineOpaContext {
175    /// Associated workflow run ID.
176    pub run_id: Uuid,
177    /// Identifier of the user who initiated the run.
178    pub initiating_user: String,
179    /// Full parsed abstract syntax tree of the workflow.
180    pub workflow_ast: Value,
181    /// JSON inputs for the workflow run.
182    pub inputs: Value,
183}