Skip to main content

praxis_policy_session_valkey/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// Parses and validates the `global.apl.session_store` block for the
5// Valkey backend. Deliberately minimal: a single endpoint, TLS,
6// auth, key prefix, optional sliding TTL, and fail-closed timeout/retry
7// knobs with committed safe defaults. Sentinel/Cluster fields are NOT
8// present — they are out of scope and would be dead config surface.
9
10use serde::Deserialize;
11
12use crate::error::BuildError;
13
14/// Default key prefix/namespace for the label keyspace. The `v1` segment
15/// lets a future value-schema change bump the namespace cleanly.
16fn default_key_prefix() -> String {
17    "taint:v1".to_owned()
18}
19
20// Committed fail-closed defaults (see plan Key Technical Decisions). They
21// ship in code so behavior and tests are deterministic; operators tune
22// from this baseline.
23fn default_connect_timeout_ms() -> u64 {
24    250
25}
26fn default_command_timeout_ms() -> u64 {
27    500
28}
29
30/// Parsed `global.apl.session_store` config for `kind: valkey`.
31///
32/// Unknown keys (including `kind`, consumed by the factory dispatch) are
33/// ignored so the same block can carry the discriminator.
34#[derive(Debug, Clone, Deserialize)]
35pub struct ValkeyConfig {
36    /// Endpoint: a `redis://` / `rediss://` URL or a bare `host:port`.
37    pub endpoint: String,
38
39    /// Whether to use TLS. Implied `true` for a `rediss://` endpoint.
40    /// Required for any non-localhost endpoint (validated).
41    #[serde(default)]
42    pub tls: bool,
43
44    /// Optional ACL username (Valkey 6+ ACLs). Paired with `password`.
45    #[serde(default)]
46    pub username: Option<String>,
47
48    /// Optional auth password / ACL secret. Sourced from config/env by
49    /// the operator; never hard-coded.
50    #[serde(default)]
51    pub password: Option<String>,
52
53    /// Key prefix/namespace for label keys.
54    #[serde(default = "default_key_prefix")]
55    pub key_prefix: String,
56
57    /// Sliding TTL in seconds, refreshed on load and append. `None`
58    /// (default) means no expiry.
59    #[serde(default)]
60    pub ttl_seconds: Option<u64>,
61
62    /// Declared maximum session-identity lifetime, used only to emit the
63    /// TTL-soundness warning when `ttl_seconds` is shorter.
64    #[serde(default)]
65    pub max_session_lifetime_seconds: Option<u64>,
66
67    /// Connection acquisition timeout (ms).
68    #[serde(default = "default_connect_timeout_ms")]
69    pub connect_timeout_ms: u64,
70
71    /// Per-command response timeout (ms) — the fail-closed hot-path knob.
72    #[serde(default = "default_command_timeout_ms")]
73    pub command_timeout_ms: u64,
74    // NOTE: bounded retry + circuit-breaker are deliberately NOT implemented
75    // in v0 (deferred follow-up). The store fails closed on the first
76    // backend error, which is safe — it just fails faster. A `max_retries`
77    // knob is intentionally absent rather than present-but-dead, so config
78    // never advertises behavior the code doesn't have.
79}
80
81impl ValkeyConfig {
82    /// Parse from the YAML config block, then validate.
83    /// # Errors
84    ///
85    /// Returns `BuildError` when the block does not deserialize, or when
86    /// validation rejects it: a non-localhost endpoint without TLS,
87    /// contradictory credentials, or a TTL too large to express as an expiry.
88    pub fn from_value(value: &serde_yaml::Value) -> Result<Self, BuildError> {
89        let cfg: ValkeyConfig =
90            serde_yaml::from_value(value.clone()).map_err(|e| BuildError::Config(e.to_string()))?;
91        cfg.validate()?;
92        Ok(cfg)
93    }
94
95    /// Enforce the non-negotiable invariants. TLS is mandatory off
96    /// localhost; a `tls: true` + plaintext `redis://` scheme is a
97    /// contradiction (would connect in cleartext); the connection URL
98    /// must build; the TTL-soundness warning is emitted here.
99    ///
100    /// All error text routes the endpoint through [`redact_endpoint`] so
101    /// embedded credentials never leak into errors or logs.
102    fn validate(&self) -> Result<(), BuildError> {
103        // A fully-formed plaintext `redis://` endpoint with `tls: true`
104        // is contradictory: tls_enabled() would say "secure" while the
105        // explicit scheme forces cleartext. Reject rather than silently
106        // connecting in the clear.
107        if self.tls && self.endpoint.starts_with("redis://") {
108            return Err(BuildError::Config(format!(
109                "`tls: true` conflicts with the plaintext `redis://` scheme in endpoint '{}'; \
110                 use a `rediss://` URL or a bare host:port",
111                redact_endpoint(&self.endpoint)
112            )));
113        }
114
115        if !self.tls_enabled() && !endpoint_is_localhost(&self.endpoint) {
116            return Err(BuildError::TlsRequired(redact_endpoint(&self.endpoint)));
117        }
118
119        // Credential-consistency checks, rejected loud at config-load rather
120        // than silently mis-connecting on first request.
121        //
122        // 1. A full `redis://`/`rediss://` endpoint carries its own
123        //    credentials; the separate `username`/`password` fields are
124        //    ignored for URL endpoints (connection_url returns early). Setting
125        //    both is ambiguous — force credentials into one place.
126        let endpoint_is_url =
127            self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://");
128        if endpoint_is_url && (self.username.is_some() || self.password.is_some()) {
129            return Err(BuildError::Config(format!(
130                "endpoint '{}' is a full URL; put credentials in the URL userinfo \
131                 (rediss://user:pass@host) or use a bare host:port — the separate \
132                 `username`/`password` fields are ignored for URL endpoints",
133                redact_endpoint(&self.endpoint)
134            )));
135        }
136
137        // 2. A `username` with no `password` would silently connect as the
138        //    default user with the username dropped. Reject the ambiguity.
139        if self.username.is_some() && self.password.is_none() {
140            return Err(BuildError::Config(
141                "`username` is set without a `password`; supply a `password` for the ACL \
142                 user, or remove `username` to connect as the default user"
143                    .to_owned(),
144            ));
145        }
146
147        // Build the URL now so a malformed endpoint / unencodable
148        // credential fails at config-load, not on first request.
149        self.connection_url()?;
150
151        // A TTL that does not fit in an i64 is rejected here rather than
152        // clamped silently, because the wrapped value is not merely wrong: it
153        // is negative, and `EXPIRE` with a non-positive TTL deletes the key at
154        // once. This store carries session taint, so the visible effect of an
155        // absurd TTL would be taint quietly failing to persist between
156        // requests, which is a downgrade rather than an outage.
157        if let Some(ttl) = self.ttl_seconds
158            && i64::try_from(ttl).is_err()
159        {
160            return Err(BuildError::Config(format!(
161                "`ttl_seconds` of {ttl} exceeds the maximum valkey accepts ({}); a TTL that \
162                     large cannot be expressed as an expiry and would delete the session key \
163                     immediately",
164                i64::MAX
165            )));
166        }
167
168        if let (Some(ttl), Some(life)) = (self.ttl_seconds, self.max_session_lifetime_seconds)
169            && ttl < life
170        {
171            tracing::warn!(
172                alarm = "session_store_ttl_unsound",
173                ttl_seconds = ttl,
174                max_session_lifetime_seconds = life,
175                "valkey session_store TTL is shorter than the declared max session lifetime; \
176                     accumulated taint can silently expire (downgrade-by-waiting)"
177            );
178        }
179        Ok(())
180    }
181
182    /// TLS is on when explicitly set or implied by a `rediss://` scheme.
183    pub fn tls_enabled(&self) -> bool {
184        self.tls || self.endpoint.starts_with("rediss://")
185    }
186
187    /// Build the `redis`/`rediss` connection URL deadpool consumes.
188    ///
189    /// Credentials are percent-encoded via the `url` crate (never naive
190    /// string interpolation), and the wire scheme always reflects
191    /// [`Self::tls_enabled`] so it cannot disagree with the validated TLS
192    /// intent. A fully-formed endpoint URL is parsed (and trusted for its
193    /// own embedded credentials); a bare `host:port` is assembled with
194    /// the configured scheme and any separate `username`/`password`.
195    /// # Errors
196    ///
197    /// Returns `BuildError::Config` when the endpoint is not a valid URL or
198    /// cannot carry the configured credentials. The endpoint is redacted in the
199    /// message, so a password in the URL is never disclosed.
200    pub fn connection_url(&self) -> Result<String, BuildError> {
201        if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") {
202            // Validate it parses; trust the operator's embedded scheme +
203            // credentials. (validate() has already rejected the
204            // tls:true + redis:// contradiction.)
205            let url = url::Url::parse(&self.endpoint).map_err(|e| {
206                BuildError::Config(format!(
207                    "invalid endpoint URL '{}': {e}",
208                    redact_endpoint(&self.endpoint)
209                ))
210            })?;
211            return Ok(url.to_string());
212        }
213
214        let scheme = if self.tls_enabled() {
215            "rediss"
216        } else {
217            "redis"
218        };
219        let mut url = url::Url::parse(&format!("{scheme}://{}", self.endpoint)).map_err(|e| {
220            BuildError::Config(format!(
221                "invalid endpoint '{}': {e}",
222                redact_endpoint(&self.endpoint)
223            ))
224        })?;
225        // Apply credentials when either is present. `validate()` guarantees a
226        // `username` is always paired with a `password`; a lone `password`
227        // (default-user AUTH) stays valid and sets an empty username.
228        if self.username.is_some() || self.password.is_some() {
229            // set_username/set_password percent-encode and reject hosts
230            // that cannot carry userinfo (e.g. cannot-be-a-base URLs).
231            url.set_username(self.username.as_deref().unwrap_or(""))
232                .map_err(|()| BuildError::Config("endpoint cannot carry credentials".to_owned()))?;
233            if let Some(password) = &self.password {
234                url.set_password(Some(password)).map_err(|()| {
235                    BuildError::Config("endpoint cannot carry credentials".to_owned())
236                })?;
237            }
238        }
239        Ok(url.to_string())
240    }
241}
242
243/// Strip any `userinfo` (`user:pass@`) from an endpoint before it appears
244/// in an error message or log line, so credentials are never disclosed.
245fn redact_endpoint(endpoint: &str) -> String {
246    if let Some((scheme, after)) = endpoint.split_once("://") {
247        if let Some((_userinfo, host)) = after.rsplit_once('@') {
248            return format!("{scheme}://***@{host}");
249        }
250        return endpoint.to_owned();
251    }
252    // Bare host:port may still carry userinfo if misconfigured.
253    if let Some((_userinfo, host)) = endpoint.rsplit_once('@') {
254        return format!("***@{host}");
255    }
256    endpoint.to_owned()
257}
258
259/// Best-effort localhost check for the TLS-required rule. Strips scheme,
260/// credentials, and port, then matches the common loopback hosts.
261fn endpoint_is_localhost(endpoint: &str) -> bool {
262    let no_scheme = endpoint
263        .strip_prefix("rediss://")
264        .or_else(|| endpoint.strip_prefix("redis://"))
265        .unwrap_or(endpoint);
266    // Drop any credentials before the host.
267    let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme);
268    // Bracketed IPv6 loopback, e.g. [::1]:6379.
269    if host_port.starts_with("[::1]") {
270        return true;
271    }
272    let host = host_port.split(':').next().unwrap_or(host_port);
273    matches!(host, "localhost" | "127.0.0.1" | "::1")
274}
275
276#[cfg(test)]
277#[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")]
278mod tests {
279    use super::*;
280
281    fn parse(yaml: &str) -> Result<ValkeyConfig, BuildError> {
282        let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
283        ValkeyConfig::from_value(&v)
284    }
285
286    #[test]
287    fn localhost_without_tls_is_allowed() {
288        let cfg = parse("kind: valkey\nendpoint: localhost:6379\n").unwrap();
289        assert_eq!(cfg.key_prefix, "taint:v1");
290        assert_eq!(cfg.connect_timeout_ms, 250);
291        assert_eq!(cfg.command_timeout_ms, 500);
292        assert!(
293            cfg.connection_url()
294                .unwrap()
295                .starts_with("redis://localhost:6379")
296        );
297    }
298
299    /// A `ttl_seconds` past `i64::MAX` used to reach `EXPIRE` as a wrapped
300    /// negative number, and valkey deletes a key whose TTL is not positive. The
301    /// visible effect would be session taint silently failing to persist between
302    /// requests: a downgrade, not an outage, and invisible in the logs.
303    #[test]
304    fn ttl_seconds_beyond_i64_is_rejected() {
305        let err =
306            parse("kind: valkey\nendpoint: localhost:6379\nttl_seconds: 18446744073709551615\n")
307                .unwrap_err();
308        let msg = format!("{err:?}");
309        assert!(
310            msg.contains("ttl_seconds"),
311            "error should name the offending field: {msg}"
312        );
313    }
314
315    #[test]
316    fn ttl_seconds_at_the_i64_boundary_is_accepted() {
317        let yaml = format!(
318            "kind: valkey\nendpoint: localhost:6379\nttl_seconds: {}\n",
319            i64::MAX
320        );
321        let cfg = parse(&yaml).expect("a TTL that fits in i64 must be accepted");
322        assert_eq!(cfg.ttl_seconds, Some(i64::MAX as u64));
323    }
324
325    #[test]
326    fn non_localhost_without_tls_is_rejected() {
327        let err = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\n").unwrap_err();
328        assert!(matches!(err, BuildError::TlsRequired(_)), "got {err:?}");
329    }
330
331    #[test]
332    fn non_localhost_with_tls_uses_rediss_scheme() {
333        let cfg = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\n").unwrap();
334        assert!(cfg.tls_enabled());
335        assert!(
336            cfg.connection_url()
337                .unwrap()
338                .starts_with("rediss://valkey.prod.internal:6379")
339        );
340    }
341
342    #[test]
343    fn rediss_scheme_implies_tls() {
344        let cfg = parse("kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\n").unwrap();
345        assert!(cfg.tls_enabled());
346        assert!(cfg.connection_url().unwrap().starts_with("rediss://"));
347    }
348
349    /// Regression for the TLS-bypass finding: `tls: true` with an explicit
350    /// plaintext `redis://` scheme must be rejected, not silently connect
351    /// in the clear.
352    #[test]
353    fn tls_true_with_plaintext_scheme_is_rejected() {
354        let err = parse("kind: valkey\nendpoint: redis://valkey.prod.internal:6379\ntls: true\n")
355            .unwrap_err();
356        assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
357    }
358
359    #[test]
360    fn credentials_are_percent_encoded_in_url() {
361        // A password with URL-significant characters must be encoded, not
362        // interpolated raw (which would corrupt the URL).
363        let cfg = parse(
364            "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gw\npassword: \"p@ss:w/rd\"\n",
365        )
366        .unwrap();
367        let url = cfg.connection_url().unwrap();
368        assert!(url.starts_with("rediss://gw:"), "url: {url}");
369        assert!(url.contains("@valkey.prod.internal:6379"), "url: {url}");
370        // The raw special chars must NOT appear unencoded in the userinfo.
371        assert!(
372            url.contains("p%40ss"),
373            "password '@' must be encoded: {url}"
374        );
375    }
376
377    /// Nit 1: a `username` with no `password` is ambiguous (would silently
378    /// connect as the default user). Reject it at config-load.
379    #[test]
380    fn username_without_password_is_rejected() {
381        let err = parse(
382            "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gateway\n",
383        )
384        .unwrap_err();
385        assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
386    }
387
388    /// Nit 2: a full URL endpoint carries its own credentials; separate
389    /// `username`/`password` fields are ignored, so supplying both is rejected.
390    #[test]
391    fn url_endpoint_with_separate_credentials_is_rejected() {
392        let err = parse(
393            "kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\nusername: gw\npassword: s3cret\n",
394        )
395        .unwrap_err();
396        assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
397    }
398
399    /// A lone `password` (no username) is the default-user AUTH case and stays
400    /// valid, producing `redis://:pass@host` (empty username).
401    #[test]
402    fn password_without_username_uses_default_user() {
403        let cfg = parse("kind: valkey\nendpoint: localhost:6379\npassword: s3cret\n").unwrap();
404        let url = cfg.connection_url().unwrap();
405        assert!(
406            url.starts_with("redis://:s3cret@localhost:6379"),
407            "url: {url}"
408        );
409    }
410
411    #[test]
412    fn missing_endpoint_is_config_error() {
413        let err = parse("kind: valkey\n").unwrap_err();
414        assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
415    }
416
417    #[test]
418    fn ipv6_loopback_without_tls_is_allowed() {
419        let cfg = parse("kind: valkey\nendpoint: \"[::1]:6379\"\n").unwrap();
420        assert!(!cfg.tls_enabled());
421    }
422
423    #[test]
424    fn redact_endpoint_strips_userinfo() {
425        assert_eq!(
426            redact_endpoint("rediss://user:secret@host:6379"),
427            "rediss://***@host:6379"
428        );
429        assert_eq!(redact_endpoint("host:6379"), "host:6379");
430    }
431
432    /// Credentials must never leak into the TLS-required error.
433    #[test]
434    fn tls_required_error_redacts_credentials() {
435        // rediss-less, non-localhost, with embedded creds, tls off → error.
436        let err =
437            parse("kind: valkey\nendpoint: redis://user:topsecret@prod.host:6379\n").unwrap_err();
438        let msg = format!("{err}");
439        assert!(
440            !msg.contains("topsecret"),
441            "error leaked credentials: {msg}"
442        );
443    }
444}