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 if public.iter().any(|p| p.as_str() == Some(action)) {
108 return true;
109 }
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 if let Some(perms) = role_def["permissions"].as_array() {
134 if perms.iter().any(|p| p.as_str() == Some(action)) {
135 has_permission = true;
136 break;
137 }
138 }
139 }
140 }
141
142 if !has_permission {
143 return false;
144 }
145
146 if user.auth_source == "api_key" {
148 let scope_ok = user.scopes.iter().any(|s| s == action) || {
149 if let Some((resource_type, _)) = action.split_once(':') {
151 let wildcard = format!("{}:*", resource_type);
152 user.scopes.iter().any(|s| s == &wildcard)
153 } else {
154 false
155 }
156 };
157 if !scope_ok {
158 return false;
159 }
160 }
161
162 true
163}
164
165pub async fn check_policy(
171 user: &PolicyUser,
172 action: &str,
173 resource: Option<&PolicyResource>,
174) -> bool {
175 if local_mode() {
177 let allowed = evaluate_local(user, action);
178 if !allowed {
179 tracing::info!(
180 user_id = %user.user_id,
181 action = %action,
182 "Local policy denied"
183 );
184 }
185 return allowed;
186 }
187
188 let url = format!("{}/{}", opa_url(), opa_path());
190 let body = OpaInput {
191 input: OpaInputBody {
192 user: user.clone(),
193 action: action.to_string(),
194 resource: resource.cloned().unwrap_or_default(),
195 },
196 };
197
198 match HTTP_CLIENT.post(&url).json(&body).send().await {
199 Ok(resp) => match resp.json::<OpaResponse>().await {
200 Ok(opa) => {
201 let allowed = opa.result.unwrap_or(false);
202 if !allowed {
203 tracing::info!(
204 user_id = %user.user_id,
205 action = %action,
206 "OPA denied"
207 );
208 }
209 allowed
210 }
211 Err(e) => {
212 tracing::error!("Failed to parse OPA response: {}", e);
213 fail_open()
214 }
215 },
216 Err(e) => {
217 tracing::error!("OPA request failed: {}", e);
218 if fail_open() {
219 tracing::warn!("OPA unreachable — failing open (ALLOW)");
220 true
221 } else {
222 tracing::warn!("OPA unreachable — failing closed (DENY)");
223 false
224 }
225 }
226 }
227}
228
229pub async fn enforce_policy(
231 user: &PolicyUser,
232 action: &str,
233 resource: Option<&PolicyResource>,
234) -> Result<(), axum::http::StatusCode> {
235 if check_policy(user, action, resource).await {
236 Ok(())
237 } else {
238 Err(axum::http::StatusCode::FORBIDDEN)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn test_admin() -> PolicyUser {
247 PolicyUser {
248 user_id: "admin-1".to_string(),
249 roles: vec!["admin".to_string()],
250 tenant_id: Some("t1".to_string()),
251 scopes: vec![],
252 auth_source: "keycloak".to_string(),
253 }
254 }
255
256 fn test_viewer() -> PolicyUser {
257 PolicyUser {
258 user_id: "viewer-1".to_string(),
259 roles: vec!["viewer".to_string()],
260 tenant_id: Some("t1".to_string()),
261 scopes: vec![],
262 auth_source: "keycloak".to_string(),
263 }
264 }
265
266 fn test_api_key_user() -> PolicyUser {
267 PolicyUser {
268 user_id: "key-user".to_string(),
269 roles: vec!["editor".to_string()],
270 tenant_id: Some("t1".to_string()),
271 scopes: vec!["tasks:read".to_string(), "tasks:write".to_string()],
272 auth_source: "api_key".to_string(),
273 }
274 }
275
276 #[test]
277 fn admin_can_access_admin() {
278 assert!(evaluate_local(&test_admin(), "admin:access"));
279 }
280
281 #[test]
282 fn viewer_can_read_tasks() {
283 assert!(evaluate_local(&test_viewer(), "tasks:read"));
284 }
285
286 #[test]
287 fn viewer_cannot_write_tasks() {
288 assert!(!evaluate_local(&test_viewer(), "tasks:write"));
289 }
290
291 #[test]
292 fn viewer_cannot_access_admin() {
293 assert!(!evaluate_local(&test_viewer(), "admin:access"));
294 }
295
296 #[test]
297 fn api_key_in_scope_allowed() {
298 assert!(evaluate_local(&test_api_key_user(), "tasks:read"));
299 }
300
301 #[test]
302 fn api_key_out_of_scope_denied() {
303 assert!(!evaluate_local(&test_api_key_user(), "admin:access"));
304 }
305
306 #[test]
307 fn api_key_no_scope_for_codebases() {
308 assert!(!evaluate_local(&test_api_key_user(), "codebases:read"));
309 }
310
311 #[test]
312 fn public_endpoint_always_allowed() {
313 let no_roles = PolicyUser {
314 user_id: "anon".to_string(),
315 roles: vec![],
316 tenant_id: None,
317 scopes: vec![],
318 auth_source: "keycloak".to_string(),
319 };
320 assert!(evaluate_local(&no_roles, "health"));
321 }
322
323 #[test]
324 fn a2a_admin_inherits_admin() {
325 let user = PolicyUser {
326 user_id: "a2a-admin-1".to_string(),
327 roles: vec!["a2a-admin".to_string()],
328 tenant_id: Some("t1".to_string()),
329 scopes: vec![],
330 auth_source: "keycloak".to_string(),
331 };
332 assert!(evaluate_local(&user, "admin:access"));
333 }
334}