dwara_core/dataplane/
replay.rs

1//! Replay time-travel debugging (DW-102): a PURE decision replayer.
2//!
3//! The gateway's request path (`dataplane::proxy`) weaves together route
4//! matching, authorization, rate limiting, transforms, and endpoint
5//! selection with live I/O (upstream dials, live GCRA counters, breaker
6//! state). Replay inverts that: given a captured request and a
7//! [`Snapshot`] (a compiled config generation), it re-runs the SAME
8//! decision logic with NO side effects — no network, no live counters,
9//! no breaker state — so an operator can ask "what would this recorded
10//! request do under THIS config?" and diff the answer across two
11//! generations.
12//!
13//! # Sandbox isolation
14//!
15//! [`decide`] is pure: it reads only its inputs (`&Snapshot`,
16//! `&ReplayRequest`) and a caller-supplied [`SimulatedCounter`] for the
17//! rate-limit simulation. It never touches the network, the filesystem,
18//! the live rate limiter, or any shared mutable gateway state. The
19//! rate-limit check uses a SIMULATED counter (not the live GCRA store),
20//! the breaker is reported from config alone (no live failure counts),
21//! and the endpoint pick is deterministic (the first healthy-by-config
22//! endpoint, no live health tracking). This is the contract that makes
23//! replay safe to run in CI and against candidate configs that have
24//! never served traffic.
25//!
26//! # Capture source
27//!
28//! The request detail (method, path, redacted headers, auth identity)
29//! is captured by the analytics raw table (DW-043, extended in DW-102
30//! with optional `request_headers_redacted` and `auth_identity`
31//! columns). The capture is opt-in and redacted via the existing PII
32//! redaction patterns (`ai::redaction`); the replay CLI reads those
33//! rows (or an exported recording) and feeds them to [`decide`].
34//!
35//! # Diffing
36//!
37//! [`DecisionDiff`] compares two [`ReplayDecision`]s (the same request
38//! under an old and a new snapshot) and reports which decision stages
39//! changed — the unit the `dwara replay` CLI emits per request, and the
40//! signal a CI gate exits non-zero on.
41
42use std::collections::HashMap;
43use std::net::IpAddr;
44
45use crate::config::{Gateway, PathRewrite, Route, RouteAction, Service};
46use crate::security::authn::Identity;
47use crate::security::authz::{self, AuthzChain, AuthzContext, Decision};
48use crate::snapshot::Snapshot;
49use crate::state::store::CredentialKind;
50
51/// One captured request fed to [`decide`]. The fields mirror what the
52/// analytics raw table stores (DW-043 + DW-102): the request shape and
53/// the redacted auth identity. Headers are ALREADY redacted at capture
54/// time (the existing PII patterns); replay never sees raw secrets.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ReplayRequest {
57    /// HTTP method (e.g. `GET`, `POST`).
58    pub method: String,
59    /// The request path (no query string); the same value route
60    /// matching sees.
61    pub path: String,
62    /// Redacted request headers as (name, value) pairs. Values have
63    /// already been scrubbed by the capture-time redaction pass; replay
64    /// treats them as opaque match inputs for header-based route
65    /// criteria and authz (none today, but reserved for future
66    /// claim-sourced rules).
67    pub headers: Vec<(String, String)>,
68    /// The authenticated consumer name, or `None` for anonymous
69    /// traffic. Captured opt-in (DW-102 `auth_identity`).
70    pub auth_identity: Option<String>,
71    /// Wall-clock ms since the Unix epoch (the analytics time domain).
72    /// Used only for the simulated rate-limit window alignment; replay
73    /// never reads the wall clock itself.
74    pub timestamp_ms: i64,
75}
76
77impl ReplayRequest {
78    /// Look up a header value (case-insensitive name match), the way
79    /// the proxy's route criteria do.
80    pub fn header(&self, name: &str) -> Option<&str> {
81        self.headers
82            .iter()
83            .find(|(n, _)| n.eq_ignore_ascii_case(name))
84            .map(|(_, v)| v.as_str())
85    }
86}
87
88/// The outcome of one stage of the replayed decision path. Each stage
89/// reports what the LIVE proxy would have decided at that point, with
90/// no side effects applied.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReplayDecision {
93    /// The matched route name, or `None` when no route matched (the
94    /// live proxy would answer 404).
95    pub matched_route: Option<String>,
96    /// The authorization verdict (`Allow` / `Deny`). `Allow` when no
97    /// rules applied (the transparent-chain case). `None` only when no
98    /// route matched — authz never runs pre-route.
99    pub authz_result: Option<Decision>,
100    /// The simulated rate-limit verdict: `true` when the request would
101    /// be admitted, `false` when over budget. `None` when no route
102    /// matched or no rate-limit rules applied.
103    pub rate_limit_result: Option<bool>,
104    /// A description of the request transforms that WOULD apply
105    /// (header/query/body op counts), or `None` when no route matched
106    /// or the route carries no transforms.
107    pub transform_result: Option<TransformSummary>,
108    /// The upstream endpoint that WOULD be selected
109    /// (`upstream/endpoint`), or `None` when no route matched, the
110    /// action is not a proxy action, or no endpoint is configured.
111    pub upstream_pick: Option<UpstreamPick>,
112}
113
114/// A summary of the transforms that would apply to a matched route
115/// (DW-028). Counts (not full op lists) keep the diff output stable
116/// across unrelated key-order changes while still surfacing a change in
117/// which transforms apply.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct TransformSummary {
120    /// Request header op count (set + add + remove + rename).
121    pub request_header_ops: usize,
122    /// Request query op count.
123    pub request_query_ops: usize,
124    /// Whether a request body (JSON) transform is configured.
125    pub request_body_transform: bool,
126    /// Response header op count.
127    pub response_header_ops: usize,
128    /// Whether a response body (JSON) transform is configured.
129    pub response_body_transform: bool,
130}
131
132/// The upstream endpoint a matched proxy route would dispatch to. The
133/// pick is deterministic for replay (the first endpoint of the resolved
134/// upstream, or the first split target's first endpoint) — replay has
135/// no live health state, so load-balancer selection is reported as the
136/// resolved upstream name plus its first endpoint.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct UpstreamPick {
139    /// The resolved upstream name.
140    pub upstream: String,
141    /// The first endpoint's `address:port` (the deterministic pick).
142    pub endpoint: String,
143    /// The load-balancer algorithm the upstream is configured with.
144    pub load_balancer: String,
145    /// The path rewrite that would apply, if any (a short label:
146    /// `strip_prefix`, `replace_prefix`, `regex`, or `none`).
147    pub path_rewrite: String,
148}
149
150/// A simulated rate-limit counter (DW-102). Replay does NOT touch the
151/// live GCRA store; instead the caller threads a counter that tracks
152/// per-key usage within a fixed window, and [`decide`] advances it the
153/// way the live limiter would. The simulation is deliberately simple
154/// (fixed-window, not GCRA): replay answers "would this request be
155/// admitted under this config's budget?" not "what is the exact GCRA
156/// TAT?" — the decision boundary is what a diff cares about.
157#[derive(Debug, Clone, Default)]
158pub struct SimulatedCounter {
159    /// (key, window_seconds) -> (window_start_ms, used).
160    counts: HashMap<(String, u64), (i64, u64)>,
161}
162
163impl SimulatedCounter {
164    /// New empty counter.
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Simulate one rate-limit check: if `requests` over `window_seconds`
170    /// is within budget for `key` at `now_ms`, reserve one unit and
171    /// return `true`; otherwise return `false` (over budget). The
172    /// window resets when `now_ms` crosses `window_start + window_s`.
173    pub fn check(&mut self, key: &str, requests: u64, window_seconds: u64, now_ms: i64) -> bool {
174        let window_ms = (window_seconds as i64) * 1000;
175        let entry = self
176            .counts
177            .entry((key.to_string(), window_seconds))
178            .or_insert((now_ms, 0));
179        // Reset the window when it has elapsed.
180        if now_ms >= entry.0 + window_ms {
181            entry.0 = now_ms;
182            entry.1 = 0;
183        }
184        if entry.1 >= requests {
185            return false;
186        }
187        entry.1 += 1;
188        true
189    }
190}
191
192/// The pure decision function (DW-102). Re-runs the gateway's decision
193/// path — route match, authz, simulated rate limit, transform
194/// resolution, upstream pick — against `snapshot` for `request`, with
195/// NO I/O and NO side effects on live gateway state. The
196/// `rate_counter` is the ONLY mutable state touched, and it is a
197/// simulated counter the caller owns (not the live limiter).
198///
199/// Returns a [`ReplayDecision`] describing what the live proxy WOULD
200/// have decided at each stage. Stages after a miss (no route, authz
201/// deny, rate-limit deny) are still reported when they can be computed
202/// without side effects — an operator diffing two configs wants to see
203/// "authz now denies" even when the live proxy would have stopped
204/// there, so the diff surfaces the full picture.
205pub fn decide(
206    snapshot: &Snapshot,
207    request: &ReplayRequest,
208    rate_counter: &mut SimulatedCounter,
209) -> ReplayDecision {
210    let gateway = snapshot.gateway();
211
212    // 1. Route matching (same precedence as proxy.rs: exact, regex,
213    //    longest prefix). The snapshot's RouteTable is the compiled
214    //    matcher; find_full returns the route index and path params.
215    let Some((idx, _params)) = snapshot.route_table().find_full(&request.path) else {
216        return ReplayDecision {
217            matched_route: None,
218            authz_result: None,
219            rate_limit_result: None,
220            transform_result: None,
221            upstream_pick: None,
222        };
223    };
224    let Some(route) = gateway.routes.get(idx) else {
225        return ReplayDecision {
226            matched_route: None,
227            authz_result: None,
228            rate_limit_result: None,
229            transform_result: None,
230            upstream_pick: None,
231        };
232    };
233
234    // 2. Authorization (pure, against the snapshot's authz rules).
235    //    Build a minimal Identity from the captured auth identity: the
236    //    consumer name plus the groups the CONFIG consumer declares
237    //    (replay has no store-managed consumers). The effective IP is
238    //    the loopback (replay has no real peer; IP ACLs are reported
239    //    against 127.0.0.1, the honest "no network" stand-in).
240    let authz_result = evaluate_authz(gateway, route, request);
241
242    // 3. Rate-limit simulation. Resolve the applicable policies
243    //    (consumer > route > service > listener > global) and simulate
244    //    each rule's windows against the counter. A request is admitted
245    //    only if EVERY window of EVERY applicable rule allows it (the
246    //    live AND-composition). Dry-run bundles report `true` (they
247    //    never enforce).
248    let rate_limit_result = evaluate_rate_limit(gateway, route, request, rate_counter);
249
250    // 4. Transform resolution (which transforms would apply).
251    let transform_result = summarize_transforms(route);
252
253    // 5. Upstream pick (deterministic: the resolved upstream's first
254    //    endpoint). Only proxy actions dial an upstream.
255    let upstream_pick = pick_upstream(gateway, route, snapshot);
256
257    ReplayDecision {
258        matched_route: Some(route.name.clone()),
259        authz_result: Some(authz_result),
260        rate_limit_result,
261        transform_result,
262        upstream_pick,
263    }
264}
265
266/// Build a minimal [`Identity`] from the captured auth identity and the
267/// config consumer record (groups come from config; replay has no
268/// store-managed consumers and no real claims).
269fn replay_identity(gateway: &Gateway, request: &ReplayRequest) -> Option<Identity> {
270    let name = request.auth_identity.as_ref()?;
271    let consumer = gateway.consumers.iter().find(|c| &c.name == name)?;
272    Some(Identity {
273        consumer_name: consumer.name.clone(),
274        credential_kind: CredentialKind::ApiKey, // replay does not know the family
275        consumer_type: consumer.consumer_type,
276        groups: consumer.groups.clone(),
277        claims: std::collections::BTreeMap::new(),
278        body_digest: None,
279        agent: consumer.agent.clone(),
280    })
281}
282
283/// Evaluate the authorization chain (pure). Mirrors the proxy's
284/// `AuthzChain` construction: consumer, route, service, listener,
285/// global (in precedence order). Listener-level rules are transparent
286/// in replay (no listener context), exactly like the proxy's
287/// direct-drive test path.
288fn evaluate_authz(gateway: &Gateway, route: &Route, request: &ReplayRequest) -> Decision {
289    let identity = replay_identity(gateway, request);
290    let consumer_cfg = identity.as_ref().and_then(|id| {
291        gateway
292            .consumers
293            .iter()
294            .find(|c| c.name == id.consumer_name)
295    });
296    let consumer_authz = consumer_cfg.and_then(|c| c.authorization.as_ref());
297    let service = gateway.services.iter().find(|s| s.name == route.service);
298    let service_authz = service.and_then(|s| s.authorization.as_ref());
299    let global_authz = gateway.authorization.as_ref();
300
301    let any_rules = consumer_authz.is_some()
302        || route.authorization.is_some()
303        || service_authz.is_some()
304        || global_authz.is_some();
305    if !any_rules {
306        return Decision::Allow;
307    }
308
309    let peer_ip: IpAddr = "127.0.0.1".parse().unwrap();
310    let ctx = AuthzContext {
311        identity: identity.as_ref(),
312        consumer_groups: identity
313            .as_ref()
314            .map(|id| id.groups.as_slice())
315            .unwrap_or(&[]),
316        peer_ip,
317        effective_ip: peer_ip,
318        geoip: None,
319    };
320    let chain = AuthzChain {
321        consumer: consumer_authz,
322        route: route.authorization.as_ref(),
323        service: service_authz,
324        listener: None,
325        global: global_authz,
326    };
327    authz::resolve(&chain, &ctx).decision
328}
329
330/// Resolve the applicable rate-limit rules (consumer, route, service,
331/// listener, global in precedence order) and simulate each against the
332/// counter. Returns `Some(true)` if admitted, `Some(false)` if over
333/// budget, `None` if no rate-limit rules apply at all.
334fn evaluate_rate_limit(
335    gateway: &Gateway,
336    route: &Route,
337    request: &ReplayRequest,
338    counter: &mut SimulatedCounter,
339) -> Option<bool> {
340    let identity = replay_identity(gateway, request);
341    let consumer_cfg = identity.as_ref().and_then(|id| {
342        gateway
343            .consumers
344            .iter()
345            .find(|c| c.name == id.consumer_name)
346    });
347    let service = gateway.services.iter().find(|s| s.name == route.service);
348    let policy_by_name = |name: &str| gateway.policies.iter().find(|p| p.name == name);
349
350    // Collect applicable policy names in precedence order (consumer,
351    // route, service, global). Listener-level is transparent (no
352    // listener context in replay).
353    let mut applicable: Vec<&str> = Vec::new();
354    if let Some(c) = consumer_cfg {
355        applicable.extend(c.policies.iter().map(String::as_str));
356    }
357    applicable.extend(route.policies.iter().map(String::as_str));
358    if let Some(s) = service {
359        applicable.extend(s.policies.iter().map(String::as_str));
360    }
361    applicable.extend(gateway.global_policies.iter().map(String::as_str));
362
363    // Collect the resolved rules from each policy. Each entry is
364    // (key, window_count, window_seconds, dry_run): the simulated
365    // counter checks one (count, window) pair at a time. A policy may
366    // contribute the legacy single-window field AND stacked rules.
367    let mut checks: Vec<(String, u64, u64, bool)> = Vec::new();
368    for name in applicable {
369        let Some(policy) = policy_by_name(name) else {
370            continue;
371        };
372        let key = rate_limit_key(policy, request, route);
373        // Legacy single-window field: `requests` per `window_seconds`,
374        // selector [route] (see the rate-limiter module docs).
375        if let Some(rl) = &policy.rate_limit {
376            checks.push((key.clone(), rl.requests, rl.window_seconds, policy.dry_run));
377        }
378        // Stacked GCRA rules (DW-017): each rule stacks one or more
379        // windows (s, minute, hour); a request is admitted only if
380        // EVERY set window allows it.
381        for rule in &policy.rate_limits {
382            for (maybe_count, window_s) in [
383                (rule.requests_per.per_second, 1u64),
384                (rule.requests_per.minute, 60u64),
385                (rule.requests_per.hour, 3600u64),
386            ] {
387                if let Some(count) = maybe_count {
388                    checks.push((key.clone(), count as u64, window_s, policy.dry_run));
389                }
390            }
391        }
392    }
393
394    if checks.is_empty() {
395        return None;
396    }
397
398    let mut admitted = true;
399    for (key, count, window_s, dry_run) in checks {
400        if dry_run {
401            // Dry-run bundles never enforce; they report as admitted.
402            continue;
403        }
404        if !counter.check(&key, count, window_s, request.timestamp_ms) {
405            admitted = false;
406        }
407    }
408    Some(admitted)
409}
410
411/// Build the rate-limit key for a policy from its rule selectors. The
412/// live limiter composes the key from the selector components; replay
413/// mirrors that with the consumer name (or loopback IP for anonymous)
414/// and the route name.
415fn rate_limit_key(
416    policy: &crate::config::Policy,
417    request: &ReplayRequest,
418    route: &Route,
419) -> String {
420    // The key is composed from the FIRST rule's selectors (all rules in
421    // a policy share the key shape in v1; the live limiter composes per
422    // rule, but the policy-level key is what the counter tracks). Fall
423    // back to a composite of consumer + route when no rules declare
424    // selectors.
425    let consumer = request
426        .auth_identity
427        .clone()
428        .unwrap_or_else(|| "127.0.0.1".to_string());
429    let mut parts: Vec<String> = Vec::new();
430    let first_rule = policy.rate_limits.first();
431    let selectors = first_rule.map(|r| r.selector.as_slice()).unwrap_or(&[]);
432    if selectors.is_empty() {
433        // Legacy single-window field: selector is [route].
434        parts.push(route.name.clone());
435    } else {
436        for sel in selectors {
437            match sel {
438                crate::config::RateLimitSelector::Ip => parts.push("127.0.0.1".to_string()),
439                crate::config::RateLimitSelector::Credential => parts.push(consumer.clone()),
440                crate::config::RateLimitSelector::Route => parts.push(route.name.clone()),
441            }
442        }
443    }
444    parts.join(":")
445}
446
447/// Summarize the route's transforms (DW-028) into a stable shape for
448/// diffing. Returns `None` when the route carries no transforms.
449fn summarize_transforms(route: &Route) -> Option<TransformSummary> {
450    let transforms = route.transforms.as_ref()?;
451    let mut summary = TransformSummary {
452        request_header_ops: 0,
453        request_query_ops: 0,
454        request_body_transform: false,
455        response_header_ops: 0,
456        response_body_transform: false,
457    };
458    if let Some(req) = transforms.request.as_ref() {
459        if let Some(h) = req.headers.as_ref() {
460            summary.request_header_ops =
461                h.set.len() + h.add.len() + h.remove.len() + h.rename.len();
462        }
463        if let Some(q) = req.query.as_ref() {
464            summary.request_query_ops = q.set.len() + q.add.len() + q.remove.len() + q.rename.len();
465        }
466        if req.body.is_some() {
467            summary.request_body_transform = true;
468        }
469    }
470    if let Some(resp) = transforms.response.as_ref() {
471        if let Some(h) = resp.headers.as_ref() {
472            summary.response_header_ops =
473                h.set.len() + h.add.len() + h.remove.len() + h.rename.len();
474        }
475        if resp.body.is_some() {
476            summary.response_body_transform = true;
477        }
478    }
479    // A transforms block with all-empty ops is still a present block;
480    // report it (the diff cares that the block exists).
481    Some(summary)
482}
483
484/// Resolve the upstream a proxy route would dispatch to (deterministic
485/// for replay: the first endpoint of the resolved upstream, or the
486/// first split target's first endpoint). Returns `None` for non-proxy
487/// actions or when no endpoint is configured.
488fn pick_upstream(gateway: &Gateway, route: &Route, snapshot: &Snapshot) -> Option<UpstreamPick> {
489    let proxy = match &route.action {
490        RouteAction::Proxy { rewrite } => rewrite,
491        _ => return None,
492    };
493    let service = gateway.services.iter().find(|s| s.name == route.service)?;
494    let upstream_name = resolve_service_upstream(service)?;
495    let upstream = gateway.upstreams.iter().find(|u| u.name == upstream_name)?;
496    let endpoint = upstream.endpoints.first()?;
497    let path_rewrite = match proxy {
498        Some(PathRewrite::StripPrefix {}) => "strip_prefix",
499        Some(PathRewrite::ReplacePrefix { .. }) => "replace_prefix",
500        Some(PathRewrite::Regex { .. }) => "regex",
501        None => "none",
502    }
503    .to_string();
504    // The rewrite regex is compiled in the snapshot; confirm it exists
505    // for a regex rewrite (mirrors the live path's contract) but do not
506    // apply it — replay reports the configured rewrite, not the
507    // rewritten path.
508    let _ = snapshot.route_table().rewrite_regex(0);
509    Some(UpstreamPick {
510        upstream: upstream.name.clone(),
511        endpoint: format!("{}:{}", endpoint.address, endpoint.port),
512        load_balancer: format!("{:?}", upstream.load_balancer).to_lowercase(),
513        path_rewrite,
514    })
515}
516
517/// Resolve the upstream name for a service (single target or the first
518/// split target with a positive weight).
519fn resolve_service_upstream(service: &Service) -> Option<String> {
520    if let Some(name) = &service.upstream {
521        return Some(name.clone());
522    }
523    if let Some(split) = &service.split {
524        // The first target with a positive weight is the deterministic
525        // pick (replay has no live weighted-random draw).
526        return split
527            .targets
528            .iter()
529            .find(|t| t.weight > 0)
530            .map(|t| t.upstream.clone());
531    }
532    None
533}
534
535/// A per-stage diff between two [`ReplayDecision`]s (the same request
536/// under an old and a new snapshot). Each field is `true` when that
537/// stage's decision changed; [`DecisionDiff::any`] is the CI-gate
538/// signal (exit 1 when any stage differs).
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct DecisionDiff {
541    /// The request path the diff is for (for report labeling).
542    pub path: String,
543    /// The matched route name changed (including a match becoming a
544    /// miss or vice versa).
545    pub route_changed: bool,
546    /// The authorization verdict changed.
547    pub authz_changed: bool,
548    /// The rate-limit verdict changed.
549    pub rate_limit_changed: bool,
550    /// The transform summary changed.
551    pub transform_changed: bool,
552    /// The upstream pick changed.
553    pub upstream_changed: bool,
554}
555
556impl DecisionDiff {
557    /// Compare two decisions for the same request `path`. Returns a
558    /// [`DecisionDiff`] with each stage's change flag set.
559    pub fn compare(path: &str, old: &ReplayDecision, new: &ReplayDecision) -> Self {
560        DecisionDiff {
561            path: path.to_string(),
562            route_changed: old.matched_route != new.matched_route,
563            authz_changed: old.authz_result != new.authz_result,
564            rate_limit_changed: old.rate_limit_result != new.rate_limit_result,
565            transform_changed: old.transform_result != new.transform_result,
566            upstream_changed: old.upstream_pick != new.upstream_pick,
567        }
568    }
569
570    /// Whether ANY stage changed (the CI-gate signal).
571    pub fn any(&self) -> bool {
572        self.route_changed
573            || self.authz_changed
574            || self.rate_limit_changed
575            || self.transform_changed
576            || self.upstream_changed
577    }
578
579    /// A one-line human-readable summary of the diff (for the CLI
580    /// report). Empty when nothing changed.
581    pub fn summary(&self) -> String {
582        if !self.any() {
583            return String::new();
584        }
585        let mut parts = Vec::new();
586        if self.route_changed {
587            parts.push("route");
588        }
589        if self.authz_changed {
590            parts.push("authz");
591        }
592        if self.rate_limit_changed {
593            parts.push("rate_limit");
594        }
595        if self.transform_changed {
596            parts.push("transforms");
597        }
598        if self.upstream_changed {
599            parts.push("upstream");
600        }
601        format!("{}: {}", self.path, parts.join(","))
602    }
603}