Skip to main content

secrets_core/
provision.rs

1//! Discovery, provisioning, and promotion — the orchestration that turns a set
2//! of pack-declared requirements into materialized secrets and ships them where
3//! they're needed.
4//!
5//! - [`discover_secret_set`] builds the canonical [`SecretSet`] for a scope and
6//!   category from parsed [`PackSecretRequirement`]s, classifying each entry as
7//!   operator-supplied or system-generated (carrying its generation policy). The
8//!   set deliberately includes generated secrets so no downstream consumer can
9//!   miss them.
10//! - [`provision`] makes every entry exist in a store: it mints the missing
11//!   generated ones (via [`generate_secret_value`]) and reports which
12//!   operator-supplied ones are still absent. It is idempotent — existing values
13//!   (including previously generated ones) are never overwritten.
14//! - [`promote`] copies a set's values from a source store into a
15//!   [`SecretsSink`] (e.g. a cloud secret manager). Because the set includes
16//!   generated secrets, cloud promotion can no longer miss them — this is the
17//!   root-cause fix for the cloud-deploy gap.
18
19use crate::errors::{Error, Result};
20use crate::generators::generate_secret_value;
21use crate::seed::SecretsStore;
22use crate::sink::SecretsSink;
23use greentic_secrets_spec::{
24    ManagedSecret, PackSecretRequirement, Scope, SecretSet, SecretSource, canonical_secret_uri,
25    generated_scope_team, normalize_team,
26};
27use greentic_types::secrets::SecretFormat;
28
29/// Build the canonical [`SecretSet`] for `scope`/`category` from a list of
30/// parsed pack requirements.
31///
32/// Each requirement's `key` (already [`canonical_secret_name`](greentic_secrets_spec::canonical_secret_name)-
33/// normalized by the reader) is rendered through [`canonical_secret_uri`] under
34/// `category` (the provider/pack id). A generated requirement's team segment is
35/// resolved with [`generated_scope_team`] (so a tenant-scoped secret lands under
36/// `_`); an operator-supplied one inherits the scope's team. Call once per pack
37/// and extend a single set across packs.
38pub fn discover_secret_set(
39    scope: Scope,
40    category: &str,
41    requirements: &[PackSecretRequirement],
42) -> Result<SecretSet> {
43    // Canonicalize the scope's team up front so the set is consistent.
44    let scope = Scope::new(scope.env(), scope.tenant(), normalize_team(scope.team()))?;
45    let mut set = SecretSet::new(scope.clone());
46
47    for req in requirements {
48        let team = match &req.generated {
49            Some(generated) => generated_scope_team(generated, scope.team()),
50            None => scope.team(),
51        };
52        let uri = canonical_secret_uri(scope.env(), scope.tenant(), team, category, &req.key)?;
53        let mut managed = match &req.generated {
54            Some(generated) => ManagedSecret::generated(uri, generated.clone()),
55            None => ManagedSecret::user_supplied(uri),
56        };
57        managed.required = req.required;
58        set.push(managed);
59    }
60
61    Ok(set)
62}
63
64/// Outcome of a [`provision`] pass.
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct ProvisionReport {
67    /// URIs that were freshly generated and written to the store.
68    pub generated: Vec<String>,
69    /// URIs already present in the store (left untouched).
70    pub already_present: Vec<String>,
71    /// Required operator-supplied URIs that are still absent.
72    pub missing_required: Vec<String>,
73    /// Optional operator-supplied URIs that are absent.
74    pub missing_optional: Vec<String>,
75}
76
77impl ProvisionReport {
78    /// True when no required operator-supplied secret is missing.
79    pub fn is_satisfied(&self) -> bool {
80        self.missing_required.is_empty()
81    }
82}
83
84/// Ensure every secret in `set` exists in `store`: mint the missing generated
85/// ones, and record which operator-supplied ones are absent.
86///
87/// Idempotent — a secret already present in the store is left untouched (the
88/// model's `regenerate_if_present` is a rotation concern handled elsewhere, not
89/// here).
90pub async fn provision(set: &SecretSet, store: &dyn SecretsStore) -> Result<ProvisionReport> {
91    let mut report = ProvisionReport::default();
92    for managed in &set.secrets {
93        let uri = managed.uri.to_string();
94        if store_has(store, &uri).await? {
95            report.already_present.push(uri);
96            continue;
97        }
98        match &managed.source {
99            SecretSource::Generated(generated) => {
100                let (bytes, format) = generate_secret_value(generated)?;
101                store.put(&uri, format, &bytes).await?;
102                report.generated.push(uri);
103            }
104            SecretSource::UserSupplied => {
105                if managed.required {
106                    report.missing_required.push(uri);
107                } else {
108                    report.missing_optional.push(uri);
109                }
110            }
111        }
112    }
113    Ok(report)
114}
115
116/// Outcome of a [`promote`] pass.
117#[derive(Debug, Default, Clone, PartialEq, Eq)]
118pub struct PromoteReport {
119    /// URIs successfully written to the sink.
120    pub promoted: Vec<String>,
121    /// URIs in the set that had no value in the source store.
122    pub missing: Vec<String>,
123}
124
125/// Copy every value in `set` from `source` into `sink`. Entries with no value in
126/// the source store are recorded in [`PromoteReport::missing`] rather than
127/// failing the whole promotion.
128pub async fn promote(
129    set: &SecretSet,
130    source: &dyn SecretsStore,
131    sink: &dyn SecretsSink,
132) -> Result<PromoteReport> {
133    let mut report = PromoteReport::default();
134    for managed in &set.secrets {
135        let uri = managed.uri.to_string();
136        match source.get(&uri).await {
137            Ok(bytes) => {
138                let format = managed.format.clone().unwrap_or(SecretFormat::Text);
139                sink.put_secret(&managed.uri, &bytes, format).await?;
140                report.promoted.push(uri);
141            }
142            Err(Error::NotFound { .. }) => report.missing.push(uri),
143            Err(err) => return Err(err),
144        }
145    }
146    Ok(report)
147}
148
149async fn store_has(store: &dyn SecretsStore, uri: &str) -> Result<bool> {
150    match store.get(uri).await {
151        Ok(_) => Ok(true),
152        Err(Error::NotFound { .. }) => Ok(false),
153        Err(err) => Err(err),
154    }
155}
156
157#[cfg(all(test, feature = "dev-store"))]
158mod tests {
159    use super::*;
160    use crate::seed::DevStore;
161    use greentic_secrets_spec::{GeneratedSecretRequirement, GeneratedSecretScope};
162    use tempfile::tempdir;
163
164    fn scope() -> Scope {
165        Scope::new("dev", "demo", None).unwrap()
166    }
167
168    fn webhook_generated() -> GeneratedSecretRequirement {
169        GeneratedSecretRequirement {
170            policy: "random".to_string(),
171            length: 32,
172            encoding: "raw_text".to_string(),
173            scope: GeneratedSecretScope {
174                level: "tenant".to_string(),
175                team: Some("_".to_string()),
176            },
177            regenerate_if_present: false,
178        }
179    }
180
181    fn requirements() -> Vec<PackSecretRequirement> {
182        vec![
183            PackSecretRequirement::user_supplied("api_key"),
184            PackSecretRequirement::generated("webhook_secret", webhook_generated()),
185        ]
186    }
187
188    const WEBHOOK_URI: &str = "secrets://dev/demo/_/messaging-telegram/webhook_secret";
189
190    #[test]
191    fn discovery_classifies_generated_vs_supplied() {
192        let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
193        assert_eq!(set.secrets.len(), 2);
194        assert_eq!(set.user_supplied().count(), 1);
195        assert_eq!(set.generated().count(), 1);
196        // A tenant-scoped generated secret lands under the team-less `_` segment.
197        assert!(set.generated().any(|m| m.uri.to_string() == WEBHOOK_URI));
198    }
199
200    #[tokio::test]
201    async fn provision_mints_generated_and_flags_missing_supplied() {
202        let dir = tempdir().unwrap();
203        let store = DevStore::with_path(dir.path().join(".dev.secrets.env")).unwrap();
204        let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
205
206        let report = provision(&set, &store).await.unwrap();
207        assert_eq!(report.generated.len(), 1, "webhook secret should be minted");
208        assert_eq!(
209            report.missing_required.len(),
210            1,
211            "api_key is operator-supplied"
212        );
213        assert!(!report.is_satisfied());
214
215        // The minted webhook secret is now readable and stable on a re-run.
216        let first = store.get(WEBHOOK_URI).await.unwrap();
217        assert_eq!(first.len(), 32);
218        let report2 = provision(&set, &store).await.unwrap();
219        assert!(report2.generated.is_empty(), "idempotent: already present");
220        assert_eq!(
221            store.get(WEBHOOK_URI).await.unwrap(),
222            first,
223            "value unchanged"
224        );
225    }
226
227    #[tokio::test]
228    async fn promote_copies_present_values_and_reports_missing() {
229        let src_dir = tempdir().unwrap();
230        let dst_dir = tempdir().unwrap();
231        let source = DevStore::with_path(src_dir.path().join(".dev.secrets.env")).unwrap();
232        let sink_store = DevStore::with_path(dst_dir.path().join(".dev.secrets.env")).unwrap();
233
234        let set = discover_secret_set(scope(), "messaging-telegram", &requirements()).unwrap();
235        // Only the generated secret gets materialized in the source.
236        provision(&set, &source).await.unwrap();
237
238        let sink = crate::sink::StoreSink::new(sink_store);
239        let report = promote(&set, &source, &sink).await.unwrap();
240        assert_eq!(
241            report.promoted.len(),
242            1,
243            "generated webhook secret promoted"
244        );
245        assert_eq!(report.missing.len(), 1, "api_key never supplied");
246        // The promoted value matches across stores.
247        assert_eq!(
248            sink.store().get(WEBHOOK_URI).await.unwrap(),
249            source.get(WEBHOOK_URI).await.unwrap()
250        );
251    }
252}