Skip to main content

codetether_agent/server/
policy.rs

1//! OPA Policy Engine Client
2//!
3//! Calls the OPA sidecar over HTTP to evaluate authorization decisions.
4//! When `OPA_URL` is not set, runs in local mode using a compiled-in
5//! copy of the role → permission mappings from `policies/data.json`.
6
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10use std::time::Duration;
11use tracing;
12
13/// OPA sidecar URL.  Defaults to the K8s sidecar address.
14fn opa_url() -> String {
15    std::env::var("OPA_URL").unwrap_or_else(|_| "http://localhost:8181".to_string())
16}
17
18/// OPA query path for the combined authz + api-key scope policy.
19fn opa_path() -> String {
20    std::env::var("OPA_AUTHZ_PATH").unwrap_or_else(|_| "v1/data/api_keys/allow".to_string())
21}
22
23/// Whether to fail open (allow) when OPA is unreachable.
24fn fail_open() -> bool {
25    std::env::var("OPA_FAIL_OPEN")
26        .unwrap_or_default()
27        .eq_ignore_ascii_case("true")
28}
29
30/// Whether to evaluate policies locally without an OPA sidecar.
31fn local_mode() -> bool {
32    std::env::var("OPA_LOCAL_MODE")
33        .unwrap_or_default()
34        .eq_ignore_ascii_case("true")
35}
36
37// ─── Shared HTTP client ──────────────────────────────────────────
38
39static 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// ─── Input / Output types ────────────────────────────────────────
48
49/// User context passed into the OPA input document.
50#[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/// Resource context (optional).
60#[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
89// ─── Local policy data (compiled in) ─────────────────────────────
90
91/// Embedded copy of `policies/data.json` for local evaluation.
92static POLICY_DATA: &str = include_str!("../../policies/data.json");
93
94/// Lightweight local policy evaluator.
95fn evaluate_local(user: &PolicyUser, action: &str) -> bool {
96    // Parse the compiled-in data.json
97    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    // Public endpoints bypass all checks.
106    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    // Resolve effective roles (with inheritance).
118    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    // Collect permissions from effective roles.
130    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    // API key scope enforcement.
147    if user.auth_source == "api_key" {
148        let scope_ok = user.scopes.iter().any(|s| s == action) || {
149            // Check wildcard scopes.
150            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
165// ─── Public API ──────────────────────────────────────────────────
166
167/// Check whether the user is allowed to perform `action`.
168///
169/// Returns `true` if allowed, `false` if denied.
170pub async fn check_policy(
171    user: &PolicyUser,
172    action: &str,
173    resource: Option<&PolicyResource>,
174) -> bool {
175    // Local mode: evaluate in-process without OPA sidecar.
176    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    // OPA sidecar mode: HTTP POST.
189    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
229/// Enforce policy — returns `Ok(())` if allowed, `Err(StatusCode)` if denied.
230pub 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}