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}