Skip to main content

shep_core/secrets/
resolve_mod.rs

1use super::format::ALL_ENVIRONMENTS;
2use super::provider::ProviderCache;
3use super::refs::SecretRef;
4use core::fmt;
5use std::collections::BTreeMap;
6
7/// What one environment can see: the operator's store plus every provider
8/// dog's, resolved against a single environment name.
9///
10/// Built once per resolution pass and read many times, so the maps are owned
11/// rather than borrowed.
12///
13/// Debug does not leak a value: it prints the environment and two counts.
14pub struct SecretView {
15    environment: String,
16    store: BTreeMap<String, BTreeMap<String, String>>,
17    providers: ProviderCache,
18}
19
20/// Redacted (IR-41): `store` and the provider cache hold secret values.
21impl fmt::Debug for SecretView {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.debug_struct("SecretView")
24            .field("environment", &self.environment)
25            .field("keys", &self.store.len())
26            .field("namespaces", &self.providers.namespace_count())
27            .finish()
28    }
29}
30
31/// The outcome of resolving one [`SecretRef`].
32///
33/// [`Self::MissingKey`] and [`Self::MissingNamespace`] are kept apart
34/// because they need different remedies: a pair no dog has pushed is a dog
35/// that has not reported yet, which a retry fixes, while a missing key is a
36/// person's to set.
37///
38/// Debug does not leak a value: [`Self::Found`] prints as `Found(..)`.
39pub enum Resolution<'a> {
40    /// The resolved value.
41    Found(&'a str),
42    /// The operator's store holds nothing for this key, or a provider has
43    /// pushed this namespace for this environment and that push lacks the
44    /// key.
45    MissingKey,
46    /// No provider dog has pushed this namespace for this environment yet.
47    MissingNamespace,
48}
49
50/// Redacted (IR-41): [`Resolution::Found`] carries a secret's value.
51impl fmt::Debug for Resolution<'_> {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(match self {
54            Self::Found(_) => "Found(..)",
55            Self::MissingKey => "MissingKey",
56            Self::MissingNamespace => "MissingNamespace",
57        })
58    }
59}
60
61impl SecretView {
62    /// A view over `store` and `providers`, resolved for `environment`.
63    #[must_use]
64    pub fn new(
65        environment: String,
66        store: BTreeMap<String, BTreeMap<String, String>>,
67        providers: ProviderCache,
68    ) -> Self {
69        Self {
70            environment,
71            store,
72            providers,
73        }
74    }
75
76    /// A view holding nothing, so a bare reference resolves to
77    /// [`Resolution::MissingKey`] and a namespaced one to
78    /// [`Resolution::MissingNamespace`].
79    #[must_use]
80    pub fn empty(environment: String) -> Self {
81        Self::new(environment, BTreeMap::new(), ProviderCache::default())
82    }
83
84    /// The environment this view resolves against.
85    #[must_use]
86    pub fn environment(&self) -> &str {
87        &self.environment
88    }
89
90    /// The value `reference` resolves to in this view's environment.
91    ///
92    /// Exact environment, then [`ALL_ENVIRONMENTS`], then nothing. There is
93    /// deliberately no fallback to another named environment: filling an
94    /// empty `staging` slot from `production` would hand a live credential
95    /// to staging the first time somebody forgot to set one.
96    ///
97    /// A miss on a namespaced reference is [`Resolution::MissingNamespace`]
98    /// unless a provider has pushed this view's own environment for that
99    /// namespace: a push carries one `(namespace, environment)` pair, so a
100    /// dog part way through `production` then `staging` has said nothing
101    /// about staging yet, and calling that a missing key would `Errored` a
102    /// staging sheep permanently for a value arriving a second later.
103    #[must_use]
104    pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
105        let table = match reference.namespace {
106            None => Some(&self.store),
107            Some(namespace) => self.providers.namespace(namespace),
108        };
109        if let Some(value) =
110            table
111                .and_then(|table| table.get(reference.key))
112                .and_then(|by_environment| {
113                    by_environment
114                        .get(&self.environment)
115                        .or_else(|| by_environment.get(ALL_ENVIRONMENTS))
116                })
117        {
118            return Resolution::Found(value.as_str());
119        }
120        match reference.namespace {
121            None => Resolution::MissingKey,
122            Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
123            Some(_) => Resolution::MissingNamespace,
124        }
125    }
126
127    /// Whether a provider has pushed `namespace` for this view's own
128    /// environment or for [`ALL_ENVIRONMENTS`].
129    ///
130    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
131    /// environment, [`resolve`](Self::resolve)'s value lookup included, so
132    /// the pair check has to agree: a namespace pushed only under `all`
133    /// counts as pushed here too, or a key genuinely absent from that push
134    /// would read as the transient `MissingNamespace` instead of the
135    /// permanent `MissingKey` it actually is.
136    fn is_pushed(&self, namespace: &str) -> bool {
137        self.providers
138            .pushed
139            .get(namespace)
140            .is_some_and(|environments| {
141                environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
142            })
143    }
144}
145
146#[cfg(test)]
147mod tests {
148
149    use super::super::provider::ProviderCache;
150    use super::super::refs::SecretRef;
151
152    use std::collections::{BTreeMap, BTreeSet};
153
154    use super::super::testing::*;
155    use super::*;
156
157    #[test]
158    fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
159        let mut store = BTreeMap::new();
160        store.insert(
161            "K".to_string(),
162            BTreeMap::from([
163                ("production".to_string(), "prod".to_string()),
164                ("all".to_string(), "fallback".to_string()),
165            ]),
166        );
167        store.insert(
168            "ONLY_ALL".to_string(),
169            BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
170        );
171        store.insert(
172            "ONLY_PROD".to_string(),
173            BTreeMap::from([("production".to_string(), "prod".to_string())]),
174        );
175
176        let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
177        assert!(matches!(
178            view.resolve(&SecretRef {
179                namespace: None,
180                key: "K"
181            }),
182            Resolution::Found("fallback")
183        ));
184        assert!(matches!(
185            view.resolve(&SecretRef {
186                namespace: None,
187                key: "ONLY_ALL"
188            }),
189            Resolution::Found("everywhere")
190        ));
191        // The whole point: staging never falls back to production's value.
192        assert!(matches!(
193            view.resolve(&SecretRef {
194                namespace: None,
195                key: "ONLY_PROD"
196            }),
197            Resolution::MissingKey
198        ));
199        assert!(matches!(
200            view.resolve(&SecretRef {
201                namespace: None,
202                key: "ABSENT"
203            }),
204            Resolution::MissingKey
205        ));
206    }
207
208    #[test]
209    fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
210        let view = SecretView::new(
211            "production".to_string(),
212            BTreeMap::new(),
213            vercel_production(),
214        );
215
216        assert!(matches!(
217            view.resolve(&SecretRef {
218                namespace: Some("vercel"),
219                key: "PRESENT"
220            }),
221            Resolution::Found("v")
222        ));
223        // The dog is up and simply does not have this key: a person's problem.
224        assert!(matches!(
225            view.resolve(&SecretRef {
226                namespace: Some("vercel"),
227                key: "ABSENT"
228            }),
229            Resolution::MissingKey
230        ));
231        // No dog has ever pushed under this name: transient, retry.
232        assert!(matches!(
233            view.resolve(&SecretRef {
234                namespace: Some("vault"),
235                key: "ANY"
236            }),
237            Resolution::MissingNamespace
238        ));
239    }
240
241    /// The case this pair set exists for. A provider pushes `production`
242    /// and then `staging`, which is the ordinary shape, and a staging sheep
243    /// spawning between the two must wait on the restart ladder rather than
244    /// `Errored` for good. Keying on the namespace alone calls the second
245    /// push's keys permanently missing the moment the first push lands.
246    #[test]
247    fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
248        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
249
250        assert!(
251            matches!(
252                view.resolve(&SecretRef {
253                    namespace: Some("vercel"),
254                    key: "PRESENT"
255                }),
256                Resolution::MissingNamespace
257            ),
258            "staging has had no push, so waiting is what fixes this"
259        );
260    }
261
262    /// The other half, and the one that must stay permanent: the pair has
263    /// been pushed and the key is not in it, so the provider genuinely does
264    /// not have it and no amount of waiting will produce one.
265    #[test]
266    fn a_pushed_pair_missing_a_key_stays_permanent() {
267        let view = SecretView::new(
268            "production".to_string(),
269            BTreeMap::new(),
270            vercel_production(),
271        );
272
273        assert!(matches!(
274            view.resolve(&SecretRef {
275                namespace: Some("vercel"),
276                key: "ABSENT"
277            }),
278            Resolution::MissingKey
279        ));
280    }
281
282    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
283    /// environment, not only the literal string `"all"`, so a key that push
284    /// genuinely lacks is permanent for a `staging` view exactly as it
285    /// would be for a `production` one.
286    #[test]
287    fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
288        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
289
290        assert!(matches!(
291            view.resolve(&SecretRef {
292                namespace: Some("vercel"),
293                key: "ABSENT"
294            }),
295            Resolution::MissingKey
296        ));
297    }
298
299    /// The value half of the same all-only push, from a pair-aware view:
300    /// the namespace resolves the key it does carry for an environment that
301    /// never received its own push.
302    #[test]
303    fn an_all_slot_push_resolves_its_key_for_every_environment() {
304        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
305
306        assert!(matches!(
307            view.resolve(&SecretRef {
308                namespace: Some("vercel"),
309                key: "PRESENT"
310            }),
311            Resolution::Found("v")
312        ));
313    }
314
315    /// An empty push is a dog saying it holds nothing for that pair, which
316    /// is an answer. Deriving the pairs from the values would lose it and
317    /// leave such a sheep retrying against a dog that has already spoken.
318    #[test]
319    fn an_empty_push_populates_the_pair_it_carried() {
320        let view = SecretView::new(
321            "production".to_string(),
322            BTreeMap::new(),
323            ProviderCache::new(
324                BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
325                BTreeMap::from([(
326                    "vercel".to_string(),
327                    BTreeSet::from(["production".to_string()]),
328                )]),
329            ),
330        );
331
332        assert!(matches!(
333            view.resolve(&SecretRef {
334                namespace: Some("vercel"),
335                key: "ANY"
336            }),
337            Resolution::MissingKey
338        ));
339    }
340
341    /// IR-41. Fails the moment somebody replaces the hand-written impl with
342    /// a derive, which is the only way this leak comes back.
343    #[test]
344    fn debug_never_prints_a_value() {
345        let store = BTreeMap::from([(
346            "K".to_string(),
347            BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
348        )]);
349        let view = SecretView::new("production".to_string(), store, ProviderCache::default());
350        let rendered = format!("{view:?}");
351        assert_eq!(
352            rendered,
353            "SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
354        );
355        assert!(!rendered.contains("hunter2"));
356    }
357
358    /// IR-41, the same guard for the type a resolved value travels in.
359    #[test]
360    fn a_resolution_debug_never_prints_the_value_it_found() {
361        assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
362        assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
363        assert_eq!(
364            format!("{:?}", Resolution::MissingNamespace),
365            "MissingNamespace"
366        );
367    }
368}