dwara_core/extensions/secrets.rs
1//! Secret source extension point.
2//!
3//! # Contract: [`SecretSource`]
4//!
5//! **Purpose:** resolve named secrets (TLS key passphrases, upstream
6//! credentials, plugin tokens) at runtime without baking values into
7//! configuration.
8//!
9//! **Semantics:** `resolve` is a single lookup, `None` meaning "this source
10//! does not know this secret" (callers may chain sources). Resolution is
11//! not on the request hot path for M1; implementations must never log the
12//! resolved value.
13//!
14//! **Failure model:** a source that knows where the secret lives but cannot
15//! read it (backend sealed, file unreadable) returns [`ExtensionsError::Io`]
16//! or [`ExtensionsError::Backend`] — distinguishable from a plain miss
17//! (`Ok(None)`). No retries.
18//!
19//! **Editions:** OSS ships [`EnvSecretSource`] (environment variables),
20//! [`FileSecretSource`] (secret files by path, DW-045), and
21//! [`StaticSecretSource`] (in-process map, for tests). Additional managed
22//! secret-store backends may be provided separately in future editions.
23//!
24//! # Config integration (DW-045)
25//!
26//! The `${...}` secret-reference grammar that secret-bearing config
27//! fields (`api_key.key`) accept is defined in
28//! [`config::credentials`](crate::config::credentials) and resolves
29//! through the same env/file reading rules these local impls use, so
30//! the config grammar cannot drift from the extension seam. References
31//! are resolved at CONFIG-COMPILE time (cold start and every reload),
32//! never per request; see that module's docs for the read-time model
33//! and the fail-closed validation contract. [`FileSecretSource`]
34//! re-reads the file on every `resolve` call (no caching): resolution
35//! happens at publish cadence, so a rotation lands on the next reload
36//! for the cost of one small read per publish.
37//!
38//! # Secret handling
39//!
40//! [`Secret`] wraps the value as a newtype over `String`. Its `Debug` impl
41//! is redacted so values cannot leak into logs. Zeroization on drop is a
42//! future hardening step; adopting the `secrecy` crate then would change
43//! the wrapper's internals, not the trait.
44
45use std::collections::HashMap;
46
47use async_trait::async_trait;
48
49use super::ExtensionsError;
50
51/// A resolved secret value.
52///
53/// `Debug` is deliberately redacted; `Display` is not implemented.
54#[derive(Clone, Default)]
55pub struct Secret(String);
56
57impl Secret {
58 pub fn new(value: impl Into<String>) -> Self {
59 Self(value.into())
60 }
61
62 /// Expose the raw value. Callers must not log or persist it.
63 pub fn expose(&self) -> &str {
64 &self.0
65 }
66}
67
68impl std::fmt::Debug for Secret {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(f, "Secret([{} bytes redacted])", self.0.len())
71 }
72}
73
74/// Swappable secret resolution backend.
75#[async_trait]
76pub trait SecretSource: Send + Sync {
77 /// Resolve `name`; `Ok(None)` means this source has no such secret.
78 async fn resolve(&self, name: &str) -> Result<Option<Secret>, ExtensionsError>;
79}
80
81/// Environment-variable secret source (OSS): `name` maps to the identical
82/// environment variable name. An unset variable is a miss (`Ok(None)`); a
83/// variable that is set but not valid Unicode is reported as
84/// [`ExtensionsError::Invalid`] (present but unreadable), never a silent miss.
85#[derive(Debug, Clone, Default)]
86pub struct EnvSecretSource;
87
88#[async_trait]
89impl SecretSource for EnvSecretSource {
90 async fn resolve(&self, name: &str) -> Result<Option<Secret>, ExtensionsError> {
91 match std::env::var(name) {
92 Ok(value) => Ok(Some(Secret::new(value))),
93 Err(std::env::VarError::NotPresent) => Ok(None),
94 Err(std::env::VarError::NotUnicode(_)) => Err(ExtensionsError::Invalid(format!(
95 "environment variable {name} is set but not valid Unicode"
96 ))),
97 }
98 }
99}
100
101/// File secret source (OSS, DW-045): `name` IS the file path. The file
102/// is read on EVERY `resolve` call (no caching — a rotation lands on
103/// the next resolve) with the same rules the config grammar applies
104/// (see [`read_secret_file`][crate::config::credentials::read_secret_file]):
105/// one trailing newline trimmed, non-empty remainder, UTF-8.
106///
107/// Failure model: for this source the name is the LOCATION, so a
108/// missing or unreadable file is NOT a miss — it is a fail-closed
109/// [`ExtensionsError::Io`] whose message names the path (never any
110/// content). This mirrors validation's fail-closed contract: a secret
111/// the config references must exist when the generation is built.
112#[derive(Debug, Clone, Default)]
113pub struct FileSecretSource;
114
115#[async_trait]
116impl SecretSource for FileSecretSource {
117 async fn resolve(&self, name: &str) -> Result<Option<Secret>, ExtensionsError> {
118 crate::config::credentials::read_secret_file(name)
119 .map(|value| Some(Secret::new(value)))
120 .map_err(ExtensionsError::Io)
121 }
122}
123
124/// Static in-process map of secrets, primarily for tests and examples.
125#[derive(Debug, Clone, Default)]
126pub struct StaticSecretSource {
127 secrets: HashMap<String, String>,
128}
129
130impl StaticSecretSource {
131 pub fn new() -> Self {
132 Self::default()
133 }
134
135 /// Insert or replace the value stored under `name`.
136 pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
137 self.secrets.insert(name.into(), value.into());
138 self
139 }
140}
141
142#[async_trait]
143impl SecretSource for StaticSecretSource {
144 async fn resolve(&self, name: &str) -> Result<Option<Secret>, ExtensionsError> {
145 Ok(self.secrets.get(name).cloned().map(Secret::new))
146 }
147}