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        && 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    // 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            && 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    // API key scope enforcement.
146    if user.auth_source == "api_key" {
147        let scope_ok = user.scopes.iter().any(|s| s == action) || {
148            // Check wildcard scopes.
149            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
164// ─── Public API ──────────────────────────────────────────────────
165
166/// Check whether the user is allowed to perform `action`.
167///
168/// Returns `true` if allowed, `false` if denied.
169pub async fn check_policy(
170    user: &PolicyUser,
171    action: &str,
172    resource: Option<&PolicyResource>,
173) -> bool {
174    // Local mode: evaluate in-process without OPA sidecar.
175    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    // OPA sidecar mode: HTTP POST.
188    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
228/// Enforce policy — returns `Ok(())` if allowed, `Err(StatusCode)` if denied.
229pub 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}