1use 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
29pub fn discover_secret_set(
39 scope: Scope,
40 category: &str,
41 requirements: &[PackSecretRequirement],
42) -> Result<SecretSet> {
43 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct ProvisionReport {
67 pub generated: Vec<String>,
69 pub already_present: Vec<String>,
71 pub missing_required: Vec<String>,
73 pub missing_optional: Vec<String>,
75}
76
77impl ProvisionReport {
78 pub fn is_satisfied(&self) -> bool {
80 self.missing_required.is_empty()
81 }
82}
83
84pub 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
118pub struct PromoteReport {
119 pub promoted: Vec<String>,
121 pub missing: Vec<String>,
123}
124
125pub 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 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 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 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 assert_eq!(
248 sink.store().get(WEBHOOK_URI).await.unwrap(),
249 source.get(WEBHOOK_URI).await.unwrap()
250 );
251 }
252}