kovra_core/resolver.rs
1//! The single-pass resolver (spec §4.3).
2//!
3//! At launch, `resolve` turns a parsed [`EnvRefs`] contract into concrete values
4//! by, per variable: substituting `${ENV}`, reading the vault through the L2
5//! registry (project→global override), reading the execution environment for
6//! `${env:}` passthrough, applying fallbacks (forbidden for `prod`, I4c), and
7//! materializing references through a provider **once per distinct ref**.
8//!
9//! It returns per-variable metadata (sensitivity, environment, coordinate) but
10//! does **not** itself confirm `high` secrets or apply the executor allowlist —
11//! that is the Wrapper's job at injection time (L5, I15). Keeping the resolver
12//! free of the broker is what lets it be tested with only mock vault + provider.
13
14use std::collections::HashMap;
15
16use zeroize::Zeroizing;
17
18use crate::audit::{AuditAction, AuditEvent, AuditSink};
19use crate::clock::Clock;
20use crate::coordinate::{Coordinate, EnvSegment, KeyHalf};
21use crate::env_source::EnvSource;
22use crate::envrefs::{EnvRefs, Source};
23use crate::error::CoreError;
24use crate::keyring::Keyring;
25use crate::policy::prod_forbids_fallback;
26use crate::provider::{SecretProvider, reference_scheme};
27use crate::record::SecretRecord;
28use crate::registry::{Registry, Resolution, VaultOrigin};
29use crate::scope::Origin;
30use crate::secret::SecretValue;
31use crate::sensitivity::Sensitivity;
32
33/// A single resolved variable, ready for the Wrapper to inject. The value is a
34/// [`SecretValue`] regardless of source (everything flows into a child env next).
35#[derive(Debug)]
36pub struct ResolvedVar {
37 /// The local variable name.
38 pub name: String,
39 /// The resolved value (zeroized, redacted `Debug`).
40 pub value: SecretValue,
41 /// Sensitivity of the backing secret — `Some` only for a vault coordinate;
42 /// `None` for literals / env passthrough. The Wrapper uses it to decide
43 /// confirmation (L5).
44 pub sensitivity: Option<Sensitivity>,
45 /// The environment this variable resolved under.
46 pub environment: String,
47 /// The canonical coordinate, `Some` only for a vault coordinate.
48 pub coordinate: Option<String>,
49 /// Which vault produced it (project vs global) — `Some` only for a vault hit.
50 /// Carried so the Wrapper/audit (§10/§13) need not re-look-up the origin.
51 pub origin: Option<VaultOrigin>,
52 /// The external reference URI when the value was materialized from a
53 /// reference (e.g. `azure-kv://…`), else `None`. `Some` ⇒ this was a
54 /// reference (the bool the audit log's scheme field is derived from).
55 pub reference: Option<String>,
56}
57
58impl ResolvedVar {
59 /// A non-vault variable (literal / env passthrough / fallback): no
60 /// sensitivity, no origin, not a reference.
61 fn plain(
62 name: &str,
63 value: SecretValue,
64 environment: String,
65 coordinate: Option<String>,
66 ) -> Self {
67 Self {
68 name: name.to_string(),
69 value,
70 sensitivity: None,
71 environment,
72 coordinate,
73 origin: None,
74 reference: None,
75 }
76 }
77}
78
79/// The full resolution of an `.env.refs`, in file order.
80#[derive(Debug)]
81pub struct Resolved {
82 /// One entry per variable, in the order declared.
83 pub vars: Vec<ResolvedVar>,
84}
85
86/// Run the §4.3 algorithm. `project_override` (e.g. a CLI flag) wins over the
87/// `.env.refs` `project =` line.
88///
89/// Each distinct external reference is materialized **once per run** through
90/// `provider`, and every such materialization is audited via `audit` as
91/// [`AuditAction::ProviderInvocation`] (§11, I12): the event records the
92/// coordinate, environment, and the URI **scheme** (`azure-kv`), and **never**
93/// the materialized value. `clock` stamps the event and `origin` records who
94/// drove the run. Audit is detection, not prevention — a sink failure is
95/// swallowed so it never blocks a legitimate run (mirrors the Wrapper, §11).
96#[allow(clippy::too_many_arguments)]
97pub fn resolve(
98 refs: &EnvRefs,
99 env: &str,
100 registry: &Registry,
101 keyring: &dyn Keyring,
102 env_source: &dyn EnvSource,
103 provider: &dyn SecretProvider,
104 audit: &dyn AuditSink,
105 clock: &dyn Clock,
106 origin: Origin,
107 project_override: Option<&str>,
108) -> Result<Resolved, CoreError> {
109 let project = project_override.or(refs.project.as_deref());
110 // Fetch the master key once: a passphrase-derived key would otherwise be
111 // re-derived (Argon2id) on every vault lookup in the loop below.
112 let master = keyring.get_master_key()?;
113 // Dedup by ref: a provider is invoked once per distinct reference per run.
114 let mut cache: HashMap<String, Zeroizing<Vec<u8>>> = HashMap::new();
115 let mut out = Vec::with_capacity(refs.vars.len());
116
117 for (name, source) in &refs.vars {
118 let resolved = match source {
119 Source::Literal(v) => {
120 ResolvedVar::plain(name, SecretValue::from(v.as_str()), env.to_string(), None)
121 }
122
123 Source::EnvPassthrough { var, fallback } => {
124 let value = env_source
125 .get(var)
126 .or_else(|| fallback.clone())
127 .ok_or_else(|| {
128 CoreError::EnvRefs(format!(
129 "`{name}`: env var `{var}` is unset and has no fallback"
130 ))
131 })?;
132 ResolvedVar::plain(name, SecretValue::from(value), env.to_string(), None)
133 }
134
135 Source::Uri { uri, fallback } => {
136 let coord = uri.parse::<Coordinate>()?.with_env(env);
137 let coord_env = literal_env(&coord, name)?;
138
139 // I4c: a `prod` coordinate may not carry a `| default` fallback.
140 if prod_forbids_fallback(&coord_env) && fallback.is_some() {
141 return Err(CoreError::EnvRefs(format!(
142 "`{name}`: a `| fallback` is forbidden for prod (I4c)"
143 )));
144 }
145
146 let coordinate = coord.canonical_path()?;
147 match registry.resolve_with_key(&coord, project, master.expose())? {
148 Resolution::Found {
149 record,
150 origin: vault_origin,
151 } => materialize_found(
152 name,
153 record,
154 coord_env,
155 coordinate,
156 vault_origin,
157 coord.half,
158 provider,
159 &mut cache,
160 audit,
161 clock,
162 origin,
163 )?,
164 Resolution::NotFound => match fallback {
165 Some(fb) => ResolvedVar::plain(
166 name,
167 SecretValue::from(fb.as_str()),
168 coord_env,
169 Some(coordinate),
170 ),
171 None => {
172 return Err(CoreError::EnvRefs(format!(
173 "`{name}`: coordinate `{coord}` did not resolve and has no fallback"
174 )));
175 }
176 },
177 }
178 }
179 };
180 out.push(resolved);
181 }
182
183 Ok(Resolved { vars: out })
184}
185
186/// Turn a found record into a [`ResolvedVar`], materializing a reference through
187/// the provider (deduped by ref via `cache`).
188#[allow(clippy::too_many_arguments)]
189fn materialize_found(
190 name: &str,
191 record: SecretRecord,
192 environment: String,
193 coordinate: String,
194 origin: VaultOrigin,
195 half: KeyHalf,
196 provider: &dyn SecretProvider,
197 cache: &mut HashMap<String, Zeroizing<Vec<u8>>>,
198 audit: &dyn AuditSink,
199 clock: &dyn Clock,
200 run_origin: Origin,
201) -> Result<ResolvedVar, CoreError> {
202 match record {
203 SecretRecord::Literal {
204 value, sensitivity, ..
205 } => Ok(ResolvedVar {
206 name: name.to_string(),
207 value,
208 sensitivity: Some(sensitivity),
209 environment,
210 coordinate: Some(coordinate),
211 origin: Some(origin),
212 reference: None,
213 }),
214 SecretRecord::Reference {
215 reference,
216 sensitivity,
217 ..
218 } => {
219 // I8: the reference is materialized at run time, never stored.
220 let bytes = match cache.get(&reference) {
221 Some(b) => b.clone(),
222 None => {
223 // §11/I12: audit each provider invocation BEFORE the value
224 // exists in scope — record the coordinate, environment, and
225 // the URI scheme only. The materialized value is NEVER put on
226 // the event (the result string is the scheme, not bytes).
227 let scheme = reference_scheme(&reference).unwrap_or("unknown");
228 let _ = audit.record(
229 &AuditEvent::new(
230 clock,
231 AuditAction::ProviderInvocation,
232 format!("scheme:{scheme}"),
233 )
234 .at(&coordinate, &environment)
235 .by(run_origin),
236 );
237 let materialized = provider.materialize(&reference)?;
238 let b = Zeroizing::new(materialized.expose().to_vec());
239 cache.insert(reference.clone(), b.clone());
240 b
241 }
242 };
243 Ok(ResolvedVar {
244 name: name.to_string(),
245 value: SecretValue::new(bytes.to_vec()),
246 sensitivity: Some(sensitivity),
247 environment,
248 coordinate: Some(coordinate),
249 origin: Some(origin),
250 reference: Some(reference),
251 })
252 }
253 SecretRecord::Keypair {
254 private,
255 public,
256 sensitivity,
257 ..
258 } => {
259 // KOV-12: a keypair coordinate in `.env.refs` injects ONE half into
260 // the child env (never returned to the caller/model — inject-only
261 // delivery, I11/I14; never argv/disk, I6/I7). The `#public`/`#private`
262 // fragment chooses which; an unspecified half defaults to **public**
263 // (the safe, non-secret default).
264 match half {
265 KeyHalf::Private => {
266 // The private half is a private-key op: carry the record's
267 // real sensitivity and the coordinate so the Wrapper gates it
268 // exactly like any inject (broker-gated for high/prod, I3/I15).
269 let private = private.ok_or_else(|| {
270 CoreError::EnvRefs(format!(
271 "`{name}`: coordinate selects `#private` but this is a public-only keypair"
272 ))
273 })?;
274 Ok(ResolvedVar {
275 name: name.to_string(),
276 value: private,
277 sensitivity: Some(sensitivity),
278 environment,
279 coordinate: Some(coordinate),
280 origin: Some(origin),
281 reference: None,
282 })
283 }
284 KeyHalf::Public | KeyHalf::Unspecified => {
285 // The public half is not a secret — inject it like a plain
286 // literal: no sensitivity, no gating (even in prod), not
287 // masked. (`coordinate: None` keeps it out of the inject gate
288 // and the §5.1 masking set, matching a non-secret value.)
289 Ok(ResolvedVar::plain(
290 name,
291 SecretValue::from(public.as_str()),
292 environment,
293 None,
294 ))
295 }
296 }
297 }
298 SecretRecord::Totp { .. } => {
299 // KOV-11: a TOTP code is time-varying and single-use — injecting it
300 // into a long-lived child env is a footgun (it would go stale and
301 // could be reused), and the seed must never be injected (I11/I14).
302 // So a TOTP coordinate is NOT resolvable as an env var: it fails
303 // explicitly here rather than silently emitting nothing or the seed.
304 // A code is produced on demand via `kovra code`, never `.env.refs`.
305 Err(CoreError::EnvRefs(format!(
306 "`{name}`: coordinate `{coordinate}` is a TOTP enrollment — its code is time-varying and single-use, so it cannot be injected via `.env.refs`; produce one on demand with `kovra code`"
307 )))
308 }
309 }
310}
311
312/// Extract the concrete environment from a coordinate, erroring if a placeholder
313/// somehow survived or the environment is empty (no `--env` provided).
314fn literal_env(coord: &Coordinate, name: &str) -> Result<String, CoreError> {
315 match &coord.environment {
316 EnvSegment::Literal(e) if !e.is_empty() => Ok(e.clone()),
317 _ => Err(CoreError::EnvRefs(format!(
318 "`{name}`: a `${{ENV}}` coordinate needs a non-empty --env"
319 ))),
320 }
321}