Skip to main content

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}