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        PolicyRule::AllowedTypedDataContracts { contracts } => {
45            eval_allowed_typed_data_contracts(policy_id, contracts, ctx)
46        }
47    }
48}
49
50fn eval_allowed_chains(policy_id: &str, chain_ids: &[String], ctx: &PolicyContext) -> PolicyResult {
51    if chain_ids.iter().any(|c| c == &ctx.chain_id) {
52        PolicyResult::allowed()
53    } else {
54        PolicyResult::denied(
55            policy_id,
56            format!("chain {} not in allowlist", ctx.chain_id),
57        )
58    }
59}
60
61fn eval_expires_at(policy_id: &str, timestamp: &str, ctx: &PolicyContext) -> PolicyResult {
62    let now = chrono::DateTime::parse_from_rfc3339(&ctx.timestamp);
63    let exp = chrono::DateTime::parse_from_rfc3339(timestamp);
64    match (now, exp) {
65        (Ok(now), Ok(exp)) if now > exp => {
66            PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
67        }
68        (Ok(_), Ok(_)) => PolicyResult::allowed(),
69        _ => PolicyResult::denied(
70            policy_id,
71            format!(
72                "invalid timestamp in expiry check: ctx={}, rule={}",
73                ctx.timestamp, timestamp
74            ),
75        ),
76    }
77}
78
79fn eval_allowed_typed_data_contracts(
80    policy_id: &str,
81    contracts: &[String],
82    ctx: &PolicyContext,
83) -> PolicyResult {
84    let td = match &ctx.typed_data {
85        None => return PolicyResult::allowed(),
86        Some(td) => td,
87    };
88
89    let contract = match &td.verifying_contract {
90        None => {
91            return PolicyResult::denied(
92                policy_id,
93                "typed data has no verifyingContract but policy requires one",
94            );
95        }
96        Some(c) => c,
97    };
98
99    let contract_lower = contract.to_lowercase();
100    if contracts.iter().any(|c| c.to_lowercase() == contract_lower) {
101        PolicyResult::allowed()
102    } else {
103        PolicyResult::denied(
104            policy_id,
105            format!("verifyingContract {contract} not in allowed list"),
106        )
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Executable policy evaluation
112// ---------------------------------------------------------------------------
113
114fn evaluate_executable(
115    exe: &str,
116    config: Option<&serde_json::Value>,
117    policy_id: &str,
118    ctx: &PolicyContext,
119) -> PolicyResult {
120    // Build stdin payload: context + policy_config
121    let mut payload = serde_json::to_value(ctx).unwrap_or_default();
122    if let Some(cfg) = config {
123        payload
124            .as_object_mut()
125            .map(|m| m.insert("policy_config".to_string(), cfg.clone()));
126    }
127
128    let stdin_bytes = match serde_json::to_vec(&payload) {
129        Ok(b) => b,
130        Err(e) => {
131            return PolicyResult::denied(policy_id, format!("failed to serialize context: {e}"))
132        }
133    };
134
135    let mut child = match Command::new(exe)
136        .stdin(std::process::Stdio::piped())
137        .stdout(std::process::Stdio::piped())
138        .stderr(std::process::Stdio::piped())
139        .spawn()
140    {
141        Ok(c) => c,
142        Err(e) => {
143            return PolicyResult::denied(policy_id, format!("failed to start executable: {e}"))
144        }
145    };
146
147    // Write stdin
148    if let Some(mut stdin) = child.stdin.take() {
149        let _ = stdin.write_all(&stdin_bytes);
150    }
151
152    // Wait with timeout (5 seconds)
153    let output = match wait_with_timeout(&mut child, Duration::from_secs(5)) {
154        Ok(output) => output,
155        Err(reason) => return PolicyResult::denied(policy_id, reason),
156    };
157
158    if !output.status.success() {
159        let stderr = String::from_utf8_lossy(&output.stderr);
160        return PolicyResult::denied(
161            policy_id,
162            format!(
163                "executable exited with {}: {}",
164                output.status,
165                stderr.trim()
166            ),
167        );
168    }
169
170    // Parse stdout as PolicyResult
171    match serde_json::from_slice::<PolicyResult>(&output.stdout) {
172        Ok(result) => {
173            if !result.allow {
174                // Ensure the policy_id is set even if the executable omitted it
175                PolicyResult::denied(
176                    policy_id,
177                    result
178                        .reason
179                        .unwrap_or_else(|| "denied by executable".into()),
180                )
181            } else {
182                PolicyResult::allowed()
183            }
184        }
185        Err(e) => PolicyResult::denied(policy_id, format!("invalid JSON from executable: {e}")),
186    }
187}
188
189fn wait_with_timeout(
190    child: &mut std::process::Child,
191    timeout: Duration,
192) -> Result<std::process::Output, String> {
193    let start = std::time::Instant::now();
194    loop {
195        match child.try_wait() {
196            Ok(Some(_status)) => {
197                // Process has exited — collect output.
198                let mut stdout = Vec::new();
199                let mut stderr = Vec::new();
200                if let Some(mut out) = child.stdout.take() {
201                    use std::io::Read;
202                    let _ = out.read_to_end(&mut stdout);
203                }
204                if let Some(mut err) = child.stderr.take() {
205                    use std::io::Read;
206                    let _ = err.read_to_end(&mut stderr);
207                }
208                let status = child.wait().map_err(|e| e.to_string())?;
209                return Ok(std::process::Output {
210                    status,
211                    stdout,
212                    stderr,
213                });
214            }
215            Ok(None) => {
216                if start.elapsed() > timeout {
217                    let _ = child.kill();
218                    let _ = child.wait();
219                    return Err(format!("executable timed out after {}s", timeout.as_secs()));
220                }
221                std::thread::sleep(Duration::from_millis(50));
222            }
223            Err(e) => return Err(format!("failed to wait on executable: {e}")),
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use ows_core::policy::{SpendingContext, TransactionContext, TypedDataContext};
232    use ows_core::PolicyAction;
233
234    fn base_context() -> PolicyContext {
235        PolicyContext {
236            chain_id: "eip155:8453".to_string(),
237            wallet_id: "wallet-1".to_string(),
238            api_key_id: "key-1".to_string(),
239            transaction: TransactionContext {
240                to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".to_string()),
241                value: Some("100000000000000000".to_string()), // 0.1 ETH
242                raw_hex: "0x02f8...".to_string(),
243                data: None,
244            },
245            spending: SpendingContext {
246                daily_total: "50000000000000000".to_string(), // 0.05 ETH already spent
247                date: "2026-03-22".to_string(),
248            },
249            timestamp: "2026-03-22T10:35:22Z".to_string(),
250            typed_data: None,
251        }
252    }
253
254    fn policy_with_rules(id: &str, rules: Vec<PolicyRule>) -> Policy {
255        Policy {
256            id: id.to_string(),
257            name: id.to_string(),
258            version: 1,
259            created_at: "2026-03-22T10:00:00Z".to_string(),
260            rules,
261            executable: None,
262            config: None,
263            action: PolicyAction::Deny,
264        }
265    }
266
267    // --- AllowedChains ---
268
269    #[test]
270    fn allowed_chains_passes_matching_chain() {
271        let ctx = base_context(); // chain_id = eip155:8453
272        let policy = policy_with_rules(
273            "chains",
274            vec![PolicyRule::AllowedChains {
275                chain_ids: vec!["eip155:8453".to_string(), "eip155:84532".to_string()],
276            }],
277        );
278
279        let result = evaluate_policies(&[policy], &ctx);
280        assert!(result.allow);
281    }
282
283    #[test]
284    fn allowed_chains_denies_non_matching() {
285        let ctx = base_context();
286        let policy = policy_with_rules(
287            "chains",
288            vec![PolicyRule::AllowedChains {
289                chain_ids: vec!["eip155:1".to_string()], // mainnet only
290            }],
291        );
292
293        let result = evaluate_policies(&[policy], &ctx);
294        assert!(!result.allow);
295        assert!(result.reason.unwrap().contains("not in allowlist"));
296    }
297
298    // --- ExpiresAt ---
299
300    #[test]
301    fn expires_at_allows_before_expiry() {
302        let ctx = base_context(); // timestamp = 2026-03-22T10:35:22Z
303        let policy = policy_with_rules(
304            "exp",
305            vec![PolicyRule::ExpiresAt {
306                timestamp: "2026-04-01T00:00:00Z".to_string(),
307            }],
308        );
309
310        let result = evaluate_policies(&[policy], &ctx);
311        assert!(result.allow);
312    }
313
314    #[test]
315    fn expires_at_denies_after_expiry() {
316        let ctx = base_context(); // timestamp = 2026-03-22T10:35:22Z
317        let policy = policy_with_rules(
318            "exp",
319            vec![PolicyRule::ExpiresAt {
320                timestamp: "2026-03-01T00:00:00Z".to_string(), // already expired
321            }],
322        );
323
324        let result = evaluate_policies(&[policy], &ctx);
325        assert!(!result.allow);
326        assert!(result.reason.unwrap().contains("expired"));
327    }
328
329    // --- Multi-rule / multi-policy AND semantics ---
330
331    #[test]
332    fn multiple_rules_all_must_pass() {
333        let ctx = base_context();
334        let policy = policy_with_rules(
335            "multi",
336            vec![
337                PolicyRule::AllowedChains {
338                    chain_ids: vec!["eip155:8453".to_string()],
339                },
340                PolicyRule::ExpiresAt {
341                    timestamp: "2026-04-01T00:00:00Z".to_string(),
342                },
343            ],
344        );
345
346        let result = evaluate_policies(&[policy], &ctx);
347        assert!(result.allow);
348    }
349
350    #[test]
351    fn short_circuits_on_first_denial() {
352        let ctx = base_context();
353        let policies = vec![
354            policy_with_rules(
355                "pass",
356                vec![PolicyRule::AllowedChains {
357                    chain_ids: vec!["eip155:8453".to_string()],
358                }],
359            ),
360            policy_with_rules(
361                "fail",
362                vec![PolicyRule::AllowedChains {
363                    chain_ids: vec!["eip155:1".to_string()], // wrong chain
364                }],
365            ),
366            policy_with_rules(
367                "never-reached",
368                vec![PolicyRule::ExpiresAt {
369                    timestamp: "2020-01-01T00:00:00Z".to_string(),
370                }],
371            ),
372        ];
373
374        let result = evaluate_policies(&policies, &ctx);
375        assert!(!result.allow);
376        assert_eq!(result.policy_id.unwrap(), "fail");
377    }
378
379    #[test]
380    fn empty_policies_allows() {
381        let ctx = base_context();
382        let result = evaluate_policies(&[], &ctx);
383        assert!(result.allow);
384    }
385
386    #[test]
387    fn policy_with_no_rules_and_no_executable_allows() {
388        let ctx = base_context();
389        let policy = policy_with_rules("empty", vec![]);
390        let result = evaluate_policies(&[policy], &ctx);
391        assert!(result.allow);
392    }
393
394    // --- Executable policy ---
395
396    #[test]
397    fn executable_invalid_json_denies() {
398        let ctx = base_context();
399        // sh without args just reads stdin, won't produce valid JSON → denied
400        let result = evaluate_executable("sh", None, "exe-invalid", &ctx);
401        assert!(!result.allow);
402    }
403
404    #[test]
405    fn executable_nonexistent_binary_denies() {
406        let ctx = base_context();
407        let result = evaluate_executable("/nonexistent/binary", None, "bad-exe", &ctx);
408        assert!(!result.allow);
409        assert!(result.reason.unwrap().contains("failed to start"));
410    }
411
412    #[test]
413    fn executable_with_script() {
414        // Create a temp script that outputs {"allow": true}
415        let dir = tempfile::tempdir().unwrap();
416        let script = dir.path().join("allow.sh");
417        std::fs::write(
418            &script,
419            "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n",
420        )
421        .unwrap();
422
423        #[cfg(unix)]
424        {
425            use std::os::unix::fs::PermissionsExt;
426            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
427        }
428
429        let ctx = base_context();
430        let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx);
431        assert!(result.allow);
432    }
433
434    #[test]
435    fn executable_deny_script() {
436        let dir = tempfile::tempdir().unwrap();
437        let script = dir.path().join("deny.sh");
438        std::fs::write(
439            &script,
440            "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n",
441        )
442        .unwrap();
443
444        #[cfg(unix)]
445        {
446            use std::os::unix::fs::PermissionsExt;
447            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
448        }
449
450        let ctx = base_context();
451        let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx);
452        assert!(!result.allow);
453        assert_eq!(result.reason.as_deref(), Some("nope"));
454        assert_eq!(result.policy_id.as_deref(), Some("script-deny"));
455    }
456
457    #[test]
458    fn executable_nonzero_exit_denies() {
459        let dir = tempfile::tempdir().unwrap();
460        let script = dir.path().join("fail.sh");
461        std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
462
463        #[cfg(unix)]
464        {
465            use std::os::unix::fs::PermissionsExt;
466            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
467        }
468
469        let ctx = base_context();
470        let result = evaluate_executable(script.to_str().unwrap(), None, "exit-fail", &ctx);
471        assert!(!result.allow);
472    }
473
474    #[test]
475    fn rules_prefilter_before_executable() {
476        // If declarative rules deny, executable should not run
477        let dir = tempfile::tempdir().unwrap();
478        // Create a marker file approach: if exe runs, it creates a file
479        let marker = dir.path().join("ran");
480        let script = dir.path().join("marker.sh");
481        std::fs::write(
482            &script,
483            format!(
484                "#!/bin/sh\ntouch {}\necho '{{\"allow\": true}}'\n",
485                marker.display()
486            ),
487        )
488        .unwrap();
489
490        #[cfg(unix)]
491        {
492            use std::os::unix::fs::PermissionsExt;
493            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
494        }
495
496        let ctx = base_context();
497        let policy = Policy {
498            id: "prefilter".to_string(),
499            name: "prefilter".to_string(),
500            version: 1,
501            created_at: "2026-03-22T10:00:00Z".to_string(),
502            rules: vec![PolicyRule::AllowedChains {
503                chain_ids: vec!["eip155:1".to_string()], // wrong chain → deny
504            }],
505            executable: Some(script.to_str().unwrap().to_string()),
506            config: None,
507            action: PolicyAction::Deny,
508        };
509
510        let result = evaluate_policies(&[policy], &ctx);
511        assert!(!result.allow);
512        assert!(!marker.exists(), "executable should not have run");
513    }
514
515    // --- AllowedTypedDataContracts ---
516
517    fn typed_data_context(verifying_contract: Option<&str>) -> TypedDataContext {
518        TypedDataContext {
519            verifying_contract: verifying_contract.map(String::from),
520            domain_chain_id: Some(8453),
521            primary_type: "PermitSingle".into(),
522            domain_name: Some("Permit2".into()),
523            domain_version: Some("1".into()),
524            raw_json: "{}".into(),
525        }
526    }
527
528    #[test]
529    fn allowed_typed_data_contracts_matching_allows() {
530        let mut ctx = base_context();
531        ctx.typed_data = Some(typed_data_context(Some(
532            "0x000000000022D473030F116dDEE9F6B43aC78BA3",
533        )));
534        let policy = policy_with_rules(
535            "td",
536            vec![PolicyRule::AllowedTypedDataContracts {
537                contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
538            }],
539        );
540        let result = evaluate_policies(&[policy], &ctx);
541        assert!(result.allow);
542    }
543
544    #[test]
545    fn allowed_typed_data_contracts_non_matching_denies() {
546        let mut ctx = base_context();
547        ctx.typed_data = Some(typed_data_context(Some("0xDEAD")));
548        let policy = policy_with_rules(
549            "td",
550            vec![PolicyRule::AllowedTypedDataContracts {
551                contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
552            }],
553        );
554        let result = evaluate_policies(&[policy], &ctx);
555        assert!(!result.allow);
556        assert!(result.reason.unwrap().contains("not in allowed list"));
557    }
558
559    #[test]
560    fn allowed_typed_data_contracts_case_insensitive() {
561        let mut ctx = base_context();
562        ctx.typed_data = Some(typed_data_context(Some(
563            "0x000000000022d473030f116ddee9f6b43ac78ba3",
564        )));
565        let policy = policy_with_rules(
566            "td",
567            vec![PolicyRule::AllowedTypedDataContracts {
568                contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
569            }],
570        );
571        let result = evaluate_policies(&[policy], &ctx);
572        assert!(result.allow);
573    }
574
575    #[test]
576    fn allowed_typed_data_contracts_no_typed_data_passes() {
577        let ctx = base_context();
578        let policy = policy_with_rules(
579            "td",
580            vec![PolicyRule::AllowedTypedDataContracts {
581                contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
582            }],
583        );
584        let result = evaluate_policies(&[policy], &ctx);
585        assert!(result.allow);
586    }
587
588    #[test]
589    fn allowed_typed_data_contracts_no_verifying_contract_denies() {
590        let mut ctx = base_context();
591        ctx.typed_data = Some(typed_data_context(None));
592        let policy = policy_with_rules(
593            "td",
594            vec![PolicyRule::AllowedTypedDataContracts {
595                contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
596            }],
597        );
598        let result = evaluate_policies(&[policy], &ctx);
599        assert!(!result.allow);
600        assert!(result.reason.unwrap().contains("no verifyingContract"));
601    }
602
603    #[test]
604    fn allowed_typed_data_contracts_empty_list_denies_everything() {
605        let mut ctx = base_context();
606        ctx.typed_data = Some(typed_data_context(Some(
607            "0x000000000022D473030F116dDEE9F6B43aC78BA3",
608        )));
609        let policy = policy_with_rules(
610            "td",
611            vec![PolicyRule::AllowedTypedDataContracts { contracts: vec![] }],
612        );
613        let result = evaluate_policies(&[policy], &ctx);
614        assert!(!result.allow);
615        assert!(result.reason.unwrap().contains("not in allowed list"));
616    }
617
618    #[test]
619    fn combined_rules_with_typed_data_contracts() {
620        let mut ctx = base_context();
621        ctx.typed_data = Some(typed_data_context(Some(
622            "0x000000000022D473030F116dDEE9F6B43aC78BA3",
623        )));
624        let policy = policy_with_rules(
625            "combined",
626            vec![
627                PolicyRule::AllowedChains {
628                    chain_ids: vec!["eip155:8453".into()],
629                },
630                PolicyRule::AllowedTypedDataContracts {
631                    contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
632                },
633                PolicyRule::ExpiresAt {
634                    timestamp: "2027-01-01T00:00:00Z".into(),
635                },
636            ],
637        );
638        let result = evaluate_policies(&[policy], &ctx);
639        assert!(result.allow);
640    }
641}