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}