Skip to main content

opssignal_core/
secrets.rs

1//! Secret lookups in config placeholders.
2//!
3//! A `${...}` placeholder whose name has a `:` reads a secret store
4//! instead of the environment:
5//!
6//! - `${aws-sm:<secret id or ARN>}`: AWS Secrets Manager
7//! - `${gcp-sm:<project>/<secret>[/<version>]}`: GCP Secret Manager
8//! - `${azure-kv:<vault>/<name>[/<version>]}`: Azure Key Vault
9//!
10//! A trailing `#field` reads one field of a JSON secret. Each store is
11//! served by its cloud's add-on, like the cloud config sources.
12//!
13//! Values are cached in memory for `$SIGNAL_SECRETS_TTL_SECS` (300 by
14//! default), since every store bills per read and the reloader polls
15//! every minute. They are never written to the config cache on disk.
16
17use crate::signal::SignalError;
18use crate::source;
19use sha2::{Digest, Sha256};
20use std::collections::{BTreeMap, HashMap};
21use std::sync::{Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24pub const TTL_ENV_VAR: &str = "SIGNAL_SECRETS_TTL_SECS";
25const DEFAULT_TTL: Duration = Duration::from_secs(300);
26
27/// The secret stores, as `(scheme, display name)`.
28pub const STORES: [(&str, &str); 3] = [
29    ("aws-sm", "AWS Secrets Manager"),
30    ("gcp-sm", "GCP Secret Manager"),
31    ("azure-kv", "Azure Key Vault"),
32];
33
34/// Whether a placeholder name (or a URL handed to an add-on) is a
35/// secret reference rather than an environment variable.
36pub fn is_ref(name: &str) -> bool {
37    name.contains(':')
38}
39
40/// A parsed `${store:path#field}` reference.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub(crate) struct SecretRef {
43    pub scheme: &'static str,
44    /// Store-specific path, such as `prod/slack` or `vault/name`.
45    pub path: String,
46    pub field: Option<String>,
47}
48
49impl SecretRef {
50    pub fn parse(name: &str) -> Result<Self, SignalError> {
51        let (scheme, rest) = name
52            .split_once(':')
53            .ok_or_else(|| config_err(format!("'{name}' is not a secret reference")))?;
54        let scheme = STORES
55            .iter()
56            .map(|(s, _)| *s)
57            .find(|s| *s == scheme)
58            .ok_or_else(|| {
59                config_err(format!(
60                    "unknown secret store '{scheme}' in ${{{name}}}; use aws-sm, gcp-sm or azure-kv"
61                ))
62            })?;
63        // An ARN has colons but never '#', so the last '#' starts the field.
64        let (path, field) = match rest.rsplit_once('#') {
65            Some((path, field)) if !field.is_empty() => (path, Some(field.to_string())),
66            Some(_) => return Err(config_err(format!("empty #field in ${{{name}}}"))),
67            None => (rest, None),
68        };
69        let parsed = SecretRef {
70            scheme,
71            path: path.to_string(),
72            field,
73        };
74        parsed.check_path()?;
75        Ok(parsed)
76    }
77
78    /// The reference without its field: what is fetched and cached, so
79    /// several fields of one JSON secret cost one read.
80    pub fn url(&self) -> String {
81        format!("{}:{}", self.scheme, self.path)
82    }
83
84    fn check_path(&self) -> Result<(), SignalError> {
85        let bad = |hint: &str| Err(config_err(format!("${{{}}}: expected {hint}", self.url())));
86        match self.scheme {
87            "aws-sm" if self.path.is_empty() => bad("aws-sm:<secret name or ARN>"),
88            "gcp-sm" if gcp_name(&self.path).is_none() => {
89                bad("gcp-sm:<project>/<secret>[/<version>] or gcp-sm:projects/<p>/secrets/<s>/versions/<v>")
90            }
91            "azure-kv" if azure_name(&self.path).is_none() => {
92                bad("azure-kv:<vault>/<name>[/<version>]")
93            }
94            _ => Ok(()),
95        }
96    }
97
98    fn cloud(&self) -> &'static str {
99        match self.scheme {
100            "aws-sm" => "aws",
101            "gcp-sm" => "gcp",
102            _ => "azure",
103        }
104    }
105
106    /// Whether this build can read the store, natively or through an
107    /// add-on. Like an unsupported config scheme, a missing add-on is
108    /// an install problem and stops startup.
109    fn check_supported(&self) -> Result<(), SignalError> {
110        let cloud = self.cloud();
111        let built_in = (cloud == "aws" && cfg!(feature = "aws"))
112            || (cloud == "gcp" && cfg!(feature = "gcp"))
113            || (cloud == "azure" && cfg!(feature = "azure"));
114        if built_in || source::has_fetcher(self.scheme) {
115            Ok(())
116        } else {
117            Err(config_err(format!(
118                "${{{}}}: {}",
119                self.url(),
120                source::missing_support(self.scheme)
121            )))
122        }
123    }
124}
125
126/// `(project, secret, version)` from either path form.
127pub(crate) fn gcp_name(path: &str) -> Option<(&str, &str, &str)> {
128    let parts: Vec<&str> = path.split('/').collect();
129    let named = match parts.as_slice() {
130        ["projects", p, "secrets", s, "versions", v] => (*p, *s, *v),
131        ["projects", p, "secrets", s] => (*p, *s, "latest"),
132        [p, s, v] => (*p, *s, *v),
133        [p, s] => (*p, *s, "latest"),
134        _ => return None,
135    };
136    let (p, s, v) = named;
137    (!p.is_empty() && !s.is_empty() && !v.is_empty()).then_some(named)
138}
139
140/// `(vault, name, version)`; an empty version is the current one.
141pub(crate) fn azure_name(path: &str) -> Option<(&str, &str, &str)> {
142    let parts: Vec<&str> = path.split('/').collect();
143    let named = match parts.as_slice() {
144        [vault, name] => (*vault, *name, ""),
145        [vault, name, version] if !version.is_empty() => (*vault, *name, *version),
146        _ => return None,
147    };
148    (!named.0.is_empty() && !named.1.is_empty()).then_some(named)
149}
150
151/// Resolved secrets, by placeholder name.
152pub(crate) type Values = HashMap<String, String>;
153
154pub(crate) struct ResolveError {
155    pub err: SignalError,
156    /// A malformed reference or a missing add-on; a store that can't be
157    /// reached is not fatal.
158    pub fatal: bool,
159}
160
161/// Resolves every reference in `names`. Each distinct secret is read at
162/// most once per TTL.
163pub(crate) async fn resolve(names: &[String]) -> Result<Values, ResolveError> {
164    let fatal = |err| ResolveError { err, fatal: true };
165    let mut refs = Vec::with_capacity(names.len());
166    for name in names {
167        let parsed = SecretRef::parse(name).map_err(fatal)?;
168        parsed.check_supported().map_err(fatal)?;
169        refs.push((name, parsed));
170    }
171    let mut raw: HashMap<String, String> = HashMap::new();
172    let mut values = Values::new();
173    for (name, secret) in refs {
174        let url = secret.url();
175        if !raw.contains_key(&url) {
176            let value = cached_read(&url).await.map_err(|e| ResolveError {
177                err: config_err(format!("${{{url}}}: {}", source::message(&e))),
178                fatal: false,
179            })?;
180            raw.insert(url.clone(), value);
181        }
182        let value = &raw[&url];
183        let value = match &secret.field {
184            Some(field) => json_field(value, field).map_err(|e| ResolveError {
185                err: config_err(format!("${{{name}}}: {e}")),
186                fatal: false,
187            })?,
188            None => value.clone(),
189        };
190        values.insert(name.clone(), value);
191    }
192    Ok(values)
193}
194
195/// A fingerprint of the resolved values, so the reloader notices a
196/// rotated secret without keeping the values around.
197pub(crate) fn digest(values: &Values) -> String {
198    if values.is_empty() {
199        return String::new();
200    }
201    let sorted: BTreeMap<_, _> = values.iter().collect();
202    let mut hasher = Sha256::new();
203    for (name, value) in sorted {
204        hasher.update(name.as_bytes());
205        hasher.update([0]);
206        hasher.update(value.as_bytes());
207        hasher.update([0]);
208    }
209    hasher
210        .finalize()
211        .iter()
212        .map(|b| format!("{b:02x}"))
213        .collect()
214}
215
216fn json_field(secret: &str, field: &str) -> Result<String, String> {
217    let value: serde_json::Value = serde_json::from_str(secret)
218        .map_err(|_| format!("the secret is not JSON, so it has no field '{field}'"))?;
219    match value.get(field) {
220        Some(serde_json::Value::String(s)) => Ok(s.clone()),
221        Some(serde_json::Value::Null) | None => Err(format!("the secret has no field '{field}'")),
222        Some(other) => Ok(other.to_string()),
223    }
224}
225
226type Cache = Mutex<HashMap<String, (Instant, String)>>;
227
228fn cache() -> &'static Cache {
229    static CACHE: OnceLock<Cache> = OnceLock::new();
230    CACHE.get_or_init(Default::default)
231}
232
233fn ttl() -> Duration {
234    std::env::var(TTL_ENV_VAR)
235        .ok()
236        .and_then(|raw| raw.trim().parse().ok())
237        .map_or(DEFAULT_TTL, Duration::from_secs)
238}
239
240async fn cached_read(url: &str) -> Result<String, SignalError> {
241    let ttl = ttl();
242    if let Some((at, value)) = cache()
243        .lock()
244        .unwrap_or_else(|e| e.into_inner())
245        .get(url)
246        .cloned()
247    {
248        if at.elapsed() < ttl {
249            return Ok(value);
250        }
251    }
252    let value = read(url).await?;
253    if !ttl.is_zero() {
254        cache()
255            .lock()
256            .unwrap_or_else(|e| e.into_inner())
257            .insert(url.to_string(), (Instant::now(), value.clone()));
258    }
259    Ok(value)
260}
261
262/// Reads one secret (`scheme:path`, no field) without the cache. Add-on
263/// packages reach this through [`source::fetch_blocking`].
264pub(crate) async fn read(url: &str) -> Result<String, SignalError> {
265    let secret = SecretRef::parse(url)?;
266    match secret.scheme {
267        #[cfg(feature = "aws")]
268        "aws-sm" => crate::cloud::aws::fetch_secret(&secret.path).await,
269        #[cfg(feature = "gcp")]
270        "gcp-sm" => {
271            let (project, name, version) = gcp_name(&secret.path).expect("checked by parse");
272            crate::cloud::gcp::fetch_secret(project, name, version).await
273        }
274        #[cfg(feature = "azure")]
275        "azure-kv" => {
276            let (vault, name, version) = azure_name(&secret.path).expect("checked by parse");
277            crate::cloud::azure::fetch_secret(vault, name, version).await
278        }
279        scheme => source::fetch_external(scheme, secret.url()).await,
280    }
281}
282
283fn config_err(msg: impl Into<String>) -> SignalError {
284    SignalError::Config(msg.into())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn parses_references() {
293        let arn =
294            SecretRef::parse("aws-sm:arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC#url")
295                .expect("parses");
296        assert_eq!(arn.scheme, "aws-sm");
297        assert_eq!(
298            arn.path,
299            "arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC"
300        );
301        assert_eq!(arn.field.as_deref(), Some("url"));
302        assert_eq!(
303            arn.url(),
304            "aws-sm:arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC"
305        );
306
307        let gcp = SecretRef::parse("gcp-sm:my-proj/slack").expect("parses");
308        assert_eq!(gcp_name(&gcp.path), Some(("my-proj", "slack", "latest")));
309        assert_eq!(
310            gcp_name("projects/p/secrets/s/versions/3"),
311            Some(("p", "s", "3"))
312        );
313        assert_eq!(gcp_name("p/s/7"), Some(("p", "s", "7")));
314
315        let kv = SecretRef::parse("azure-kv:ops-vault/slack-url").expect("parses");
316        assert_eq!(azure_name(&kv.path), Some(("ops-vault", "slack-url", "")));
317        assert_eq!(azure_name("v/n/abc123"), Some(("v", "n", "abc123")));
318    }
319
320    #[test]
321    fn rejects_bad_references() {
322        for bad in [
323            "vault:x",
324            "aws-sm:",
325            "aws-sm:x#",
326            "gcp-sm:only-project",
327            "gcp-sm:a/b/c/d",
328            "gcp-sm:/s",
329            "azure-kv:vault",
330            "azure-kv:vault/name/",
331            "azure-kv:/name",
332        ] {
333            assert!(SecretRef::parse(bad).is_err(), "{bad} should be rejected");
334        }
335    }
336
337    #[test]
338    fn reads_json_fields() {
339        let secret = r#"{"url":"https://hooks.example.test/x","port":8443}"#;
340        assert_eq!(
341            json_field(secret, "url").unwrap(),
342            "https://hooks.example.test/x"
343        );
344        assert_eq!(json_field(secret, "port").unwrap(), "8443");
345        assert!(json_field(secret, "missing").is_err());
346        assert!(json_field("plain", "url").is_err());
347    }
348
349    #[test]
350    fn digest_ignores_order_and_changes_with_values() {
351        let a: Values = [("x".into(), "1".into()), ("y".into(), "2".into())].into();
352        let b: Values = [("y".into(), "2".into()), ("x".into(), "1".into())].into();
353        let c: Values = [("x".into(), "1".into()), ("y".into(), "3".into())].into();
354        assert_eq!(digest(&a), digest(&b));
355        assert_ne!(digest(&a), digest(&c));
356        assert_eq!(digest(&Values::new()), "");
357    }
358}