Skip to main content

ows_lib/
policy_engine.rs

1use ows_core::{Policy, PolicyContext, PolicyResult, PolicyRule};
2use std::io::Write as _;
3use std::process::Command;
4use std::time::Duration;
5
6/// Evaluate all policies against a context. AND semantics: short-circuits on
7/// first denial. Returns `PolicyResult::allowed()` if every policy passes.
8pub fn evaluate_policies(policies: &[Policy], context: &PolicyContext) -> PolicyResult {
9    for policy in policies {
10        let result = evaluate_one(policy, context);
11        if !result.allow {
12            return result;
13        }
14    }
15    PolicyResult::allowed()
16}
17
18/// Evaluate a single policy: declarative rules first, then executable (if any).
19fn evaluate_one(policy: &Policy, context: &PolicyContext) -> PolicyResult {
20    // Declarative rules — fast, in-process
21    for rule in &policy.rules {
22        let result = evaluate_rule(rule, &policy.id, context);
23        if !result.allow {
24            return result;
25        }
26    }
27
28    // Executable — only if declarative rules passed
29    if let Some(ref exe) = policy.executable {
30        return evaluate_executable(exe, policy.config.as_ref(), &policy.id, context);
31    }
32
33    PolicyResult::allowed()
34}
35
36// ---------------------------------------------------------------------------
37// Declarative rule evaluation
38// ---------------------------------------------------------------------------
39
40fn evaluate_rule(rule: &PolicyRule, policy_id: &str, ctx: &PolicyContext) -> PolicyResult {
41    match rule {
42        PolicyRule::AllowedChains { chain_ids } => eval_allowed_chains(policy_id, chain_ids, ctx),
43        PolicyRule::ExpiresAt { timestamp } => eval_expires_at(policy_id, timestamp, ctx),
44    }
45}
46
47fn eval_allowed_chains(policy_id: &str, chain_ids: &[String], ctx: &PolicyContext) -> PolicyResult {
48    if chain_ids.iter().any(|c| c == &ctx.chain_id) {
49        PolicyResult::allowed()
50    } else {
51        PolicyResult::denied(
52            policy_id,
53            format!("chain {} not in allowlist", ctx.chain_id),
54        )
55    }
56}
57
58fn eval_expires_at(policy_id: &str, timestamp: &str, ctx: &PolicyContext) -> PolicyResult {
59    let now = chrono::DateTime::parse_from_rfc3339(&ctx.timestamp);
60    let exp = chrono::DateTime::parse_from_rfc3339(timestamp);
61    match (now, exp) {
62        (Ok(now), Ok(exp)) if now > exp => {
63            PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
64        }
65        (Ok(_), Ok(_)) => PolicyResult::allowed(),
66        _ => PolicyResult::denied(
67            policy_id,
68            format!(
69                "invalid timestamp in expiry check: ctx={}, rule={}",
70                ctx.timestamp, timestamp
71            ),
72        ),
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Executable policy evaluation
78// ---------------------------------------------------------------------------
79
80fn evaluate_executable(
81    exe: &str,
82    config: Option<&serde_json::Value>,
83    policy_id: &str,
84    ctx: &PolicyContext,
85) -> PolicyResult {
86    // Build stdin payload: context + policy_config
87    let mut payload = serde_json::to_value(ctx).unwrap_or_default();
88    if let Some(cfg) = config {
89        payload
90            .as_object_mut()
91            .map(|m| m.insert("policy_config".to_string(), cfg.clone()));
92    }
93
94    let stdin_bytes = match serde_json::to_vec(&payload) {
95        Ok(b) => b,
96        Err(e) => {
97            return PolicyResult::denied(policy_id, format!("failed to serialize context: {e}"))
98        }
99    };
100
101    let mut child = match Command::new(exe)
102        .stdin(std::process::Stdio::piped())
103        .stdout(std::process::Stdio::piped())
104        .stderr(std::process::Stdio::piped())
105        .spawn()
106    {
107        Ok(c) => c,
108        Err(e) => {
109            return PolicyResult::denied(policy_id, format!("failed to start executable: {e}"))
110        }
111    };
112
113    // Write stdin
114    if let Some(mut stdin) = child.stdin.take() {
115        let _ = stdin.write_all(&stdin_bytes);
116    }
117
118    // Wait with timeout (5 seconds)
119    let output = match wait_with_timeout(&mut child, Duration::from_secs(5)) {
120        Ok(output) => output,
121        Err(reason) => return PolicyResult::denied(policy_id, reason),
122    };
123
124    if !output.status.success() {
125        let stderr = String::from_utf8_lossy(&output.stderr);
126        return PolicyResult::denied(
127            policy_id,
128            format!(
129                "executable exited with {}: {}",
130                output.status,
131                stderr.trim()
132            ),
133        );
134    }
135
136    // Parse stdout as PolicyResult
137    match serde_json::from_slice::<PolicyResult>(&output.stdout) {
138        Ok(result) => {
139            if !result.allow {
140                // Ensure the policy_id is set even if the executable omitted it
141                PolicyResult::denied(
142                    policy_id,
143                    result
144                        .reason
145                        .unwrap_or_else(|| "denied by executable".into()),
146                )
147            } else {
148                PolicyResult::allowed()
149            }
150        }
151        Err(e) => PolicyResult::denied(policy_id, format!("invalid JSON from executable: {e}")),
152    }
153}
154
155fn wait_with_timeout(
156    child: &mut std::process::Child,
157    timeout: Duration,
158) -> Result<std::process::Output, String> {
159    let start = std::time::Instant::now();
160    loop {
161        match child.try_wait() {
162            Ok(Some(_status)) => {
163                // Process has exited — collect output.
164                let mut stdout = Vec::new();
165                let mut stderr = Vec::new();
166                if let Some(mut out) = child.stdout.take() {
167                    use std::io::Read;
168                    let _ = out.read_to_end(&mut stdout);
169                }
170                if let Some(mut err) = child.stderr.take() {
171                    use std::io::Read;
172                    let _ = err.read_to_end(&mut stderr);
173                }
174                let status = child.wait().map_err(|e| e.to_string())?;
175                return Ok(std::process::Output {
176                    status,
177                    stdout,
178                    stderr,
179                });
180            }
181            Ok(None) => {
182                if start.elapsed() > timeout {
183                    let _ = child.kill();
184                    let _ = child.wait();
185                    return Err(format!("executable timed out after {}s", timeout.as_secs()));
186                }
187                std::thread::sleep(Duration::from_millis(50));
188            }
189            Err(e) => return Err(format!("failed to wait on executable: {e}")),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use ows_core::policy::{SpendingContext, TransactionContext};
198    use ows_core::PolicyAction;
199
200    fn base_context() -> PolicyContext {
201        PolicyContext {
202            chain_id: "eip155:8453".to_string(),
203            wallet_id: "wallet-1".to_string(),
204            api_key_id: "key-1".to_string(),
205            transaction: TransactionContext {
206                to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".to_string()),
207                value: Some("100000000000000000".to_string()), // 0.1 ETH
208                raw_hex: "0x02f8...".to_string(),
209                data: None,
210            },
211            spending: SpendingContext {
212                daily_total: "50000000000000000".to_string(), // 0.05 ETH already spent
213                date: "2026-03-22".to_string(),
214            },
215            timestamp: "2026-03-22T10:35:22Z".to_string(),
216        }
217    }
218
219    fn policy_with_rules(id: &str, rules: Vec<PolicyRule>) -> Policy {
220        Policy {
221            id: id.to_string(),
222            name: id.to_string(),
223            version: 1,
224            created_at: "2026-03-22T10:00:00Z".to_string(),
225            rules,
226            executable: None,
227            config: None,
228            action: PolicyAction::Deny,
229        }
230    }
231
232    // --- AllowedChains ---
233
234    #[test]
235    fn allowed_chains_passes_matching_chain() {
236        let ctx = base_context(); // chain_id = eip155:8453
237        let policy = policy_with_rules(
238            "chains",
239            vec![PolicyRule::AllowedChains {
240                chain_ids: vec!["eip155:8453".to_string(), "eip155:84532".to_string()],
241            }],
242        );
243
244        let result = evaluate_policies(&[policy], &ctx);
245        assert!(result.allow);
246    }
247
248    #[test]
249    fn allowed_chains_denies_non_matching() {
250        let ctx = base_context();
251        let policy = policy_with_rules(
252            "chains",
253            vec![PolicyRule::AllowedChains {
254                chain_ids: vec!["eip155:1".to_string()], // mainnet only
255            }],
256        );
257
258        let result = evaluate_policies(&[policy], &ctx);
259        assert!(!result.allow);
260        assert!(result.reason.unwrap().contains("not in allowlist"));
261    }
262
263    // --- ExpiresAt ---
264
265    #[test]
266    fn expires_at_allows_before_expiry() {
267        let ctx = base_context(); // timestamp = 2026-03-22T10:35:22Z
268        let policy = policy_with_rules(
269            "exp",
270            vec![PolicyRule::ExpiresAt {
271                timestamp: "2026-04-01T00:00:00Z".to_string(),
272            }],
273        );
274
275        let result = evaluate_policies(&[policy], &ctx);
276        assert!(result.allow);
277    }
278
279    #[test]
280    fn expires_at_denies_after_expiry() {
281        let ctx = base_context(); // timestamp = 2026-03-22T10:35:22Z
282        let policy = policy_with_rules(
283            "exp",
284            vec![PolicyRule::ExpiresAt {
285                timestamp: "2026-03-01T00:00:00Z".to_string(), // already expired
286            }],
287        );
288
289        let result = evaluate_policies(&[policy], &ctx);
290        assert!(!result.allow);
291        assert!(result.reason.unwrap().contains("expired"));
292    }
293
294    // --- Multi-rule / multi-policy AND semantics ---
295
296    #[test]
297    fn multiple_rules_all_must_pass() {
298        let ctx = base_context();
299        let policy = policy_with_rules(
300            "multi",
301            vec![
302                PolicyRule::AllowedChains {
303                    chain_ids: vec!["eip155:8453".to_string()],
304                },
305                PolicyRule::ExpiresAt {
306                    timestamp: "2026-04-01T00:00:00Z".to_string(),
307                },
308            ],
309        );
310
311        let result = evaluate_policies(&[policy], &ctx);
312        assert!(result.allow);
313    }
314
315    #[test]
316    fn short_circuits_on_first_denial() {
317        let ctx = base_context();
318        let policies = vec![
319            policy_with_rules(
320                "pass",
321                vec![PolicyRule::AllowedChains {
322                    chain_ids: vec!["eip155:8453".to_string()],
323                }],
324            ),
325            policy_with_rules(
326                "fail",
327                vec![PolicyRule::AllowedChains {
328                    chain_ids: vec!["eip155:1".to_string()], // wrong chain
329                }],
330            ),
331            policy_with_rules(
332                "never-reached",
333                vec![PolicyRule::ExpiresAt {
334                    timestamp: "2020-01-01T00:00:00Z".to_string(),
335                }],
336            ),
337        ];
338
339        let result = evaluate_policies(&policies, &ctx);
340        assert!(!result.allow);
341        assert_eq!(result.policy_id.unwrap(), "fail");
342    }
343
344    #[test]
345    fn empty_policies_allows() {
346        let ctx = base_context();
347        let result = evaluate_policies(&[], &ctx);
348        assert!(result.allow);
349    }
350
351    #[test]
352    fn policy_with_no_rules_and_no_executable_allows() {
353        let ctx = base_context();
354        let policy = policy_with_rules("empty", vec![]);
355        let result = evaluate_policies(&[policy], &ctx);
356        assert!(result.allow);
357    }
358
359    // --- Executable policy ---
360
361    #[test]
362    fn executable_invalid_json_denies() {
363        let ctx = base_context();
364        // sh without args just reads stdin, won't produce valid JSON → denied
365        let result = evaluate_executable("sh", None, "exe-invalid", &ctx);
366        assert!(!result.allow);
367    }
368
369    #[test]
370    fn executable_nonexistent_binary_denies() {
371        let ctx = base_context();
372        let result = evaluate_executable("/nonexistent/binary", None, "bad-exe", &ctx);
373        assert!(!result.allow);
374        assert!(result.reason.unwrap().contains("failed to start"));
375    }
376
377    #[test]
378    fn executable_with_script() {
379        // Create a temp script that outputs {"allow": true}
380        let dir = tempfile::tempdir().unwrap();
381        let script = dir.path().join("allow.sh");
382        std::fs::write(
383            &script,
384            "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n",
385        )
386        .unwrap();
387
388        #[cfg(unix)]
389        {
390            use std::os::unix::fs::PermissionsExt;
391            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
392        }
393
394        let ctx = base_context();
395        let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx);
396        assert!(result.allow);
397    }
398
399    #[test]
400    fn executable_deny_script() {
401        let dir = tempfile::tempdir().unwrap();
402        let script = dir.path().join("deny.sh");
403        std::fs::write(
404            &script,
405            "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n",
406        )
407        .unwrap();
408
409        #[cfg(unix)]
410        {
411            use std::os::unix::fs::PermissionsExt;
412            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
413        }
414
415        let ctx = base_context();
416        let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx);
417        assert!(!result.allow);
418        assert_eq!(result.reason.as_deref(), Some("nope"));
419        assert_eq!(result.policy_id.as_deref(), Some("script-deny"));
420    }
421
422    #[test]
423    fn executable_nonzero_exit_denies() {
424        let dir = tempfile::tempdir().unwrap();
425        let script = dir.path().join("fail.sh");
426        std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
427
428        #[cfg(unix)]
429        {
430            use std::os::unix::fs::PermissionsExt;
431            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
432        }
433
434        let ctx = base_context();
435        let result = evaluate_executable(script.to_str().unwrap(), None, "exit-fail", &ctx);
436        assert!(!result.allow);
437    }
438
439    #[test]
440    fn rules_prefilter_before_executable() {
441        // If declarative rules deny, executable should not run
442        let dir = tempfile::tempdir().unwrap();
443        // Create a marker file approach: if exe runs, it creates a file
444        let marker = dir.path().join("ran");
445        let script = dir.path().join("marker.sh");
446        std::fs::write(
447            &script,
448            format!(
449                "#!/bin/sh\ntouch {}\necho '{{\"allow\": true}}'\n",
450                marker.display()
451            ),
452        )
453        .unwrap();
454
455        #[cfg(unix)]
456        {
457            use std::os::unix::fs::PermissionsExt;
458            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
459        }
460
461        let ctx = base_context();
462        let policy = Policy {
463            id: "prefilter".to_string(),
464            name: "prefilter".to_string(),
465            version: 1,
466            created_at: "2026-03-22T10:00:00Z".to_string(),
467            rules: vec![PolicyRule::AllowedChains {
468                chain_ids: vec!["eip155:1".to_string()], // wrong chain → deny
469            }],
470            executable: Some(script.to_str().unwrap().to_string()),
471            config: None,
472            action: PolicyAction::Deny,
473        };
474
475        let result = evaluate_policies(&[policy], &ctx);
476        assert!(!result.allow);
477        assert!(!marker.exists(), "executable should not have run");
478    }
479}