1use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10use std::time::Duration;
11use tracing;
12
13fn opa_url() -> String {
15 std::env::var("OPA_URL").unwrap_or_else(|_| "http://localhost:8181".to_string())
16}
17
18fn opa_path() -> String {
20 std::env::var("OPA_AUTHZ_PATH").unwrap_or_else(|_| "v1/data/api_keys/allow".to_string())
21}
22
23fn fail_open() -> bool {
25 std::env::var("OPA_FAIL_OPEN")
26 .unwrap_or_default()
27 .eq_ignore_ascii_case("true")
28}
29
30fn local_mode() -> bool {
32 std::env::var("OPA_LOCAL_MODE")
33 .unwrap_or_default()
34 .eq_ignore_ascii_case("true")
35}
36
37static HTTP_CLIENT: LazyLock<Client> = LazyLock::new(|| {
40 Client::builder()
41 .timeout(Duration::from_secs(2))
42 .pool_max_idle_per_host(4)
43 .build()
44 .expect("failed to build reqwest client")
45});
46
47#[derive(Debug, Clone, Serialize)]
51pub struct PolicyUser {
52 pub user_id: String,
53 pub roles: Vec<String>,
54 pub tenant_id: Option<String>,
55 pub scopes: Vec<String>,
56 pub auth_source: String,
57}
58
59#[derive(Debug, Clone, Default, Serialize)]
61pub struct PolicyResource {
62 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
63 pub resource_type: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub id: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub owner_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub tenant_id: Option<String>,
70}
71
72#[derive(Serialize)]
73struct OpaInput {
74 input: OpaInputBody,
75}
76
77#[derive(Serialize)]
78struct OpaInputBody {
79 user: PolicyUser,
80 action: String,
81 resource: PolicyResource,
82}
83
84#[derive(Deserialize)]
85struct OpaResponse {
86 result: Option<bool>,
87}
88
89static POLICY_DATA: &str = include_str!("../../policies/data.json");
93
94fn evaluate_local(user: &PolicyUser, action: &str) -> bool {
96 let data: serde_json::Value = match serde_json::from_str(POLICY_DATA) {
98 Ok(d) => d,
99 Err(e) => {
100 tracing::error!("Failed to parse embedded policy data: {}", e);
101 return false;
102 }
103 };
104
105 if let Some(public) = data["public_endpoints"].as_array()
107 && public.iter().any(|p| p.as_str() == Some(action))
108 {
109 return true;
110 }
111
112 let roles_data = match data["roles"].as_object() {
113 Some(r) => r,
114 None => return false,
115 };
116
117 let mut effective_roles: Vec<&str> = Vec::new();
119 for role in &user.roles {
120 if let Some(role_def) = roles_data.get(role.as_str()) {
121 if let Some(parent) = role_def["inherits"].as_str() {
122 effective_roles.push(parent);
123 } else {
124 effective_roles.push(role.as_str());
125 }
126 }
127 }
128
129 let mut has_permission = false;
131 for role in &effective_roles {
132 if let Some(role_def) = roles_data.get(*role)
133 && let Some(perms) = role_def["permissions"].as_array()
134 && perms.iter().any(|p| p.as_str() == Some(action))
135 {
136 has_permission = true;
137 break;
138 }
139 }
140
141 if !has_permission {
142 return false;
143 }
144
145 if user.auth_source == "api_key" {
147 let scope_ok = user.scopes.iter().any(|s| s == action) || {
148 if let Some((resource_type, _)) = action.split_once(':') {
150 let wildcard = format!("{}:*", resource_type);
151 user.scopes.iter().any(|s| s == &wildcard)
152 } else {
153 false
154 }
155 };
156 if !scope_ok {
157 return false;
158 }
159 }
160
161 true
162}
163
164pub async fn check_policy(
170 user: &PolicyUser,
171 action: &str,
172 resource: Option<&PolicyResource>,
173) -> bool {
174 if local_mode() {
176 let allowed = evaluate_local(user, action);
177 if !allowed {
178 tracing::info!(
179 user_id = %user.user_id,
180 action = %action,
181 "Local policy denied"
182 );
183 }
184 return allowed;
185 }
186
187 let url = format!("{}/{}", opa_url(), opa_path());
189 let body = OpaInput {
190 input: OpaInputBody {
191 user: user.clone(),
192 action: action.to_string(),
193 resource: resource.cloned().unwrap_or_default(),
194 },
195 };
196
197 match HTTP_CLIENT.post(&url).json(&body).send().await {
198 Ok(resp) => match resp.json::<OpaResponse>().await {
199 Ok(opa) => {
200 let allowed = opa.result.unwrap_or(false);
201 if !allowed {
202 tracing::info!(
203 user_id = %user.user_id,
204 action = %action,
205 "OPA denied"
206 );
207 }
208 allowed
209 }
210 Err(e) => {
211 tracing::error!("Failed to parse OPA response: {}", e);
212 fail_open()
213 }
214 },
215 Err(e) => {
216 tracing::error!("OPA request failed: {}", e);
217 if fail_open() {
218 tracing::warn!("OPA unreachable — failing open (ALLOW)");
219 true
220 } else {
221 tracing::warn!("OPA unreachable — failing closed (DENY)");
222 false
223 }
224 }
225 }
226}
227
228pub async fn enforce_policy(
230 user: &PolicyUser,
231 action: &str,
232 resource: Option<&PolicyResource>,
233) -> Result<(), axum::http::StatusCode> {
234 if check_policy(user, action, resource).await {
235 Ok(())
236 } else {
237 Err(axum::http::StatusCode::FORBIDDEN)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 fn test_admin() -> PolicyUser {
246 PolicyUser {
247 user_id: "admin-1".to_string(),
248 roles: vec!["admin".to_string()],
249 tenant_id: Some("t1".to_string()),
250 scopes: vec![],
251 auth_source: "keycloak".to_string(),
252 }
253 }
254
255 fn test_viewer() -> PolicyUser {
256 PolicyUser {
257 user_id: "viewer-1".to_string(),
258 roles: vec!["viewer".to_string()],
259 tenant_id: Some("t1".to_string()),
260 scopes: vec![],
261 auth_source: "keycloak".to_string(),
262 }
263 }
264
265 fn test_api_key_user() -> PolicyUser {
266 PolicyUser {
267 user_id: "key-user".to_string(),
268 roles: vec!["editor".to_string()],
269 tenant_id: Some("t1".to_string()),
270 scopes: vec!["tasks:read".to_string(), "tasks:write".to_string()],
271 auth_source: "api_key".to_string(),
272 }
273 }
274
275 #[test]
276 fn admin_can_access_admin() {
277 assert!(evaluate_local(&test_admin(), "admin:access"));
278 }
279
280 #[test]
281 fn viewer_can_read_tasks() {
282 assert!(evaluate_local(&test_viewer(), "tasks:read"));
283 }
284
285 #[test]
286 fn viewer_cannot_write_tasks() {
287 assert!(!evaluate_local(&test_viewer(), "tasks:write"));
288 }
289
290 #[test]
291 fn viewer_cannot_access_admin() {
292 assert!(!evaluate_local(&test_viewer(), "admin:access"));
293 }
294
295 #[test]
296 fn api_key_in_scope_allowed() {
297 assert!(evaluate_local(&test_api_key_user(), "tasks:read"));
298 }
299
300 #[test]
301 fn api_key_out_of_scope_denied() {
302 assert!(!evaluate_local(&test_api_key_user(), "admin:access"));
303 }
304
305 #[test]
306 fn api_key_no_scope_for_workspaces() {
307 assert!(!evaluate_local(&test_api_key_user(), "workspaces:read"));
308 }
309
310 #[test]
311 fn public_endpoint_always_allowed() {
312 let no_roles = PolicyUser {
313 user_id: "anon".to_string(),
314 roles: vec![],
315 tenant_id: None,
316 scopes: vec![],
317 auth_source: "keycloak".to_string(),
318 };
319 assert!(evaluate_local(&no_roles, "health"));
320 }
321
322 #[test]
323 fn a2a_admin_inherits_admin() {
324 let user = PolicyUser {
325 user_id: "a2a-admin-1".to_string(),
326 roles: vec!["a2a-admin".to_string()],
327 tenant_id: Some("t1".to_string()),
328 scopes: vec![],
329 auth_source: "keycloak".to_string(),
330 };
331 assert!(evaluate_local(&user, "admin:access"));
332 }
333}