Skip to main content

kovra_core/
provider.rs

1//! External secret providers for **reference** secrets (spec §6).
2//!
3//! A reference secret stores only a pointer (`azure-kv://vault/name`, …); its
4//! value is materialized **at run time** by invoking the provider with the
5//! executing environment's own identity, and is never stored (I8). This module
6//! defines the seam (the [`SecretProvider`] trait) plus the [`SchemeRouter`]
7//! that dispatches a reference to the registered provider for its URI scheme.
8//!
9//! The resolver **knows nothing of Azure** (§6.1): it holds a `&dyn
10//! SecretProvider` and the concrete provider impl (e.g. `kovra-providers-azure`)
11//! is injected by the CLI/FFI. `core` therefore never depends on any provider
12//! crate — adding a provider is registering an impl on the router, touching
13//! neither the resolver, the grammar, nor this trait.
14
15use crate::error::CoreError;
16use crate::secret::SecretValue;
17
18/// Materializes a reference URI into its value. Behind a trait so the resolver
19/// is tested with [`MockProvider`]; real providers (L6+) shell out to a cloud CLI
20/// with the executing environment's own identity (§6.2). A provider declares the
21/// URI [`scheme`](SecretProvider::scheme) it handles so the [`SchemeRouter`] can
22/// dispatch by scheme without knowing the concrete type.
23pub trait SecretProvider {
24    /// Fetch the value for a reference URI (e.g. `azure-kv://corp-kv/db-url`).
25    fn materialize(&self, reference: &str) -> Result<SecretValue, CoreError>;
26
27    /// The URI scheme this provider handles (`"azure-kv"`, `"aws-sm"`, …),
28    /// without the `://`. The [`SchemeRouter`] dispatches on it.
29    fn scheme(&self) -> &'static str;
30}
31
32/// The scheme of a reference URI (`azure-kv://vault/name` → `azure-kv`), or
33/// `None` when the string has no `://` separator. Never returns the rest of the
34/// URI — only the scheme token, which is safe to log/audit (it is not a value).
35pub fn reference_scheme(reference: &str) -> Option<&str> {
36    reference.split_once("://").map(|(scheme, _)| scheme)
37}
38
39/// Dispatches a reference to the registered provider for its URI scheme (§6.1).
40///
41/// Built by the CLI/FFI with the concrete provider impls (e.g. the Azure
42/// provider); `core` depends only on the trait, never on a provider crate. An
43/// unknown scheme falls through to [`UnsupportedProvider`], yielding a clear
44/// "unsupported scheme" error rather than a silent empty or a fabricated value.
45#[derive(Default)]
46pub struct SchemeRouter {
47    providers: Vec<Box<dyn SecretProvider>>,
48}
49
50impl SchemeRouter {
51    /// An empty router (every reference is unsupported until a provider is
52    /// registered).
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Register a provider for its declared scheme (builder style). The last
58    /// registration for a scheme wins (callers register one impl per scheme).
59    pub fn with(mut self, provider: Box<dyn SecretProvider>) -> Self {
60        self.providers.push(provider);
61        self
62    }
63
64    /// The registered provider handling `scheme`, if any.
65    fn provider_for(&self, scheme: &str) -> Option<&dyn SecretProvider> {
66        self.providers
67            .iter()
68            .rev()
69            .find(|p| p.scheme() == scheme)
70            .map(|p| p.as_ref())
71    }
72}
73
74impl SecretProvider for SchemeRouter {
75    fn materialize(&self, reference: &str) -> Result<SecretValue, CoreError> {
76        let scheme = reference_scheme(reference).ok_or_else(|| {
77            // No `://` at all — never invent a value; report the malformed form
78            // without echoing it as if it were a coordinate.
79            CoreError::Provider(format!(
80                "reference `{reference}` is malformed: expected `<scheme>://…`"
81            ))
82        })?;
83        match self.provider_for(scheme) {
84            Some(p) => p.materialize(reference),
85            // Unknown scheme → the explicit unsupported error (never silent).
86            None => UnsupportedProvider.materialize(reference),
87        }
88    }
89
90    /// The router itself has no single scheme; it dispatches across many.
91    fn scheme(&self) -> &'static str {
92        "*"
93    }
94}
95
96/// A provider that refuses every reference with a clear "unsupported scheme"
97/// error — the [`SchemeRouter`]'s fallback for an unknown scheme, and the
98/// stand-in when no provider crate is wired in (e.g. a build without Azure).
99pub struct UnsupportedProvider;
100
101impl SecretProvider for UnsupportedProvider {
102    fn materialize(&self, reference: &str) -> Result<SecretValue, CoreError> {
103        let scheme = reference_scheme(reference).unwrap_or(reference);
104        Err(CoreError::Provider(format!(
105            "no provider registered for reference scheme `{scheme}` (supported: azure-kv, aws-sm)"
106        )))
107    }
108
109    fn scheme(&self) -> &'static str {
110        // Sentinel — `UnsupportedProvider` is never registered under a scheme;
111        // it is only used as the router's fallback.
112        ""
113    }
114}
115
116/// Deterministic provider for tests: maps a reference string to a value and
117/// counts how many times each reference was materialized (to prove dedup).
118#[derive(Default)]
119pub struct MockProvider {
120    entries: std::collections::HashMap<String, Vec<u8>>,
121    calls: std::sync::Mutex<std::collections::HashMap<String, usize>>,
122}
123
124impl MockProvider {
125    /// An empty provider.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Register a reference → value mapping (builder style).
131    pub fn with(mut self, reference: &str, value: &str) -> Self {
132        self.entries
133            .insert(reference.to_string(), value.as_bytes().to_vec());
134        self
135    }
136
137    /// How many times `reference` was materialized.
138    pub fn call_count(&self, reference: &str) -> usize {
139        self.calls
140            .lock()
141            .expect("mock provider mutex poisoned")
142            .get(reference)
143            .copied()
144            .unwrap_or(0)
145    }
146}
147
148impl SecretProvider for MockProvider {
149    fn materialize(&self, reference: &str) -> Result<SecretValue, CoreError> {
150        *self
151            .calls
152            .lock()
153            .expect("mock provider mutex poisoned")
154            .entry(reference.to_string())
155            .or_insert(0) += 1;
156        match self.entries.get(reference) {
157            Some(bytes) => Ok(SecretValue::new(bytes.clone())),
158            None => Err(CoreError::EnvRefs(format!(
159                "provider has no value for reference `{reference}`"
160            ))),
161        }
162    }
163
164    fn scheme(&self) -> &'static str {
165        "azure-kv"
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn mock_materializes_and_counts() {
175        let p = MockProvider::new().with("azure-kv://kv/db-url", "postgres://h/db");
176        assert_eq!(
177            p.materialize("azure-kv://kv/db-url").unwrap().expose(),
178            b"postgres://h/db"
179        );
180        assert_eq!(p.call_count("azure-kv://kv/db-url"), 1);
181        assert!(p.materialize("azure-kv://kv/missing").is_err());
182    }
183
184    #[test]
185    fn reference_scheme_splits_on_separator() {
186        assert_eq!(reference_scheme("azure-kv://kv/name"), Some("azure-kv"));
187        assert_eq!(reference_scheme("aws-sm://arn:..."), Some("aws-sm"));
188        assert_eq!(reference_scheme("no-separator"), None);
189    }
190
191    #[test]
192    fn router_dispatches_by_scheme() {
193        let router =
194            SchemeRouter::new().with(Box::new(MockProvider::new().with("azure-kv://kv/n", "v")));
195        assert_eq!(
196            router.materialize("azure-kv://kv/n").unwrap().expose(),
197            b"v"
198        );
199    }
200
201    #[test]
202    fn router_unknown_scheme_is_unsupported_not_silent() {
203        let router = SchemeRouter::new();
204        let err = router.materialize("aws-sm://kv/n").unwrap_err();
205        assert!(matches!(err, CoreError::Provider(_)));
206        // names the scheme, never fabricates a value
207        assert!(format!("{err}").contains("aws-sm"));
208    }
209
210    #[test]
211    fn router_malformed_reference_errors() {
212        let router = SchemeRouter::new();
213        assert!(matches!(
214            router.materialize("not-a-uri").unwrap_err(),
215            CoreError::Provider(_)
216        ));
217    }
218
219    // ---- KOV-28 hardening: scheme-splitter / router fuzzing ----
220    mod fuzz {
221        use super::*;
222        use proptest::prelude::*;
223
224        proptest! {
225            // `reference_scheme` is total and never panics: it returns `Some`
226            // exactly when the input contains "://", and the returned token is
227            // precisely the prefix before the first "://". It never carries the
228            // rest of the URI — only the scheme, which is the part safe to audit
229            // (I12: a reference value never enters a log via the scheme split).
230            #[test]
231            fn reference_scheme_is_total_and_leak_free(s in ".*") {
232                match reference_scheme(&s) {
233                    Some(scheme) => {
234                        prop_assert!(s.contains("://"));
235                        prop_assert!(!scheme.contains("://"));
236                        prop_assert!(s.starts_with(scheme));
237                        prop_assert_eq!(scheme, s.split("://").next().unwrap());
238                    }
239                    None => prop_assert!(!s.contains("://")),
240                }
241            }
242
243            // An empty router never fabricates a value: every reference errors,
244            // never a silent empty or a made-up secret. A reference with no "://"
245            // is the explicit malformed error; an unknown scheme is the explicit
246            // unsupported error — both `CoreError::Provider`, neither an `Ok`.
247            #[test]
248            fn empty_router_never_fabricates(s in ".*") {
249                let router = SchemeRouter::new();
250                prop_assert!(router.materialize(&s).is_err());
251            }
252        }
253    }
254}