secrets_core/
seed.rs

1use crate::SecretsBackend;
2use crate::broker::SecretsBroker;
3use crate::crypto::envelope::EnvelopeService;
4use crate::errors::{Error, Result};
5use crate::key_provider::KeyProvider;
6use crate::spec_compat::{ContentType, SecretMeta, Visibility};
7use crate::uri::SecretUri;
8use async_trait::async_trait;
9use base64::{Engine, engine::general_purpose::STANDARD};
10use greentic_secrets_spec::{SeedDoc, SeedEntry, SeedValue};
11use greentic_types::secrets::{SecretFormat, SecretRequirement, SecretScope};
12#[cfg(feature = "schema-validate")]
13use jsonschema::JSONSchema;
14use reqwest::Client;
15use std::sync::{Arc, Mutex};
16
17/// Minimal dev context used for resolving requirement keys into URIs.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct DevContext {
20    pub env: String,
21    pub tenant: String,
22    pub team: Option<String>,
23}
24
25impl DevContext {
26    pub fn new(env: impl Into<String>, tenant: impl Into<String>, team: Option<String>) -> Self {
27        Self {
28            env: env.into(),
29            tenant: tenant.into(),
30            team,
31        }
32    }
33}
34
35/// Resolve a requirement into a concrete URI for dev flows.
36pub fn resolve_uri(ctx: &DevContext, req: &SecretRequirement) -> String {
37    let team = ctx.team.as_deref().unwrap_or("_");
38    format!(
39        "secrets://{}/{}/{}/{}",
40        ctx.env,
41        ctx.tenant,
42        team,
43        req.key.as_str()
44    )
45}
46
47/// Normalized seed entry with bytes payload.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct NormalizedSeedEntry {
50    pub uri: String,
51    pub format: SecretFormat,
52    pub bytes: Vec<u8>,
53    pub description: Option<String>,
54}
55
56/// Errors encountered while applying a seed entry.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ApplyFailure {
59    pub uri: String,
60    pub error: String,
61}
62
63/// Summary report from seed application.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct ApplyReport {
66    pub ok: usize,
67    pub failed: Vec<ApplyFailure>,
68}
69
70/// Options for applying seeds.
71#[derive(Default)]
72pub struct ApplyOptions<'a> {
73    pub requirements: Option<&'a [SecretRequirement]>,
74    pub validate_schema: bool,
75}
76
77#[async_trait]
78pub trait SecretsStore: Send + Sync {
79    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()>;
80    async fn get(&self, uri: &str) -> Result<Vec<u8>>;
81}
82
83/// Apply all entries in a seed document to the provided store.
84pub async fn apply_seed<S: SecretsStore + ?Sized>(
85    store: &S,
86    seed: &SeedDoc,
87    options: ApplyOptions<'_>,
88) -> ApplyReport {
89    let mut ok = 0usize;
90    let mut failed = Vec::new();
91
92    for entry in &seed.entries {
93        if let Err(err) = validate_entry(entry, &options) {
94            failed.push(ApplyFailure {
95                uri: entry.uri.clone(),
96                error: err.to_string(),
97            });
98            continue;
99        }
100
101        match normalize_seed_entry(entry) {
102            Ok(normalized) => {
103                if let Err(err) = store
104                    .put(&normalized.uri, normalized.format, &normalized.bytes)
105                    .await
106                {
107                    failed.push(ApplyFailure {
108                        uri: normalized.uri,
109                        error: err.to_string(),
110                    });
111                } else {
112                    ok += 1;
113                }
114            }
115            Err(err) => failed.push(ApplyFailure {
116                uri: entry.uri.clone(),
117                error: err.to_string(),
118            }),
119        }
120    }
121
122    ApplyReport { ok, failed }
123}
124
125fn normalize_seed_entry(entry: &SeedEntry) -> Result<NormalizedSeedEntry> {
126    let bytes = match (&entry.format, &entry.value) {
127        (SecretFormat::Text, SeedValue::Text { text }) => Ok(text.as_bytes().to_vec()),
128        (SecretFormat::Json, SeedValue::Json { json }) => {
129            serde_json::to_vec(json).map_err(|err| Error::Invalid("json".into(), err.to_string()))
130        }
131        (SecretFormat::Bytes, SeedValue::BytesB64 { bytes_b64 }) => STANDARD
132            .decode(bytes_b64.as_bytes())
133            .map_err(|err| Error::Invalid("bytes_b64".into(), err.to_string())),
134        _ => Err(Error::Invalid(
135            "seed".into(),
136            "format/value mismatch".into(),
137        )),
138    }?;
139
140    Ok(NormalizedSeedEntry {
141        uri: entry.uri.clone(),
142        format: entry.format.clone(),
143        bytes,
144        description: entry.description.clone(),
145    })
146}
147
148fn validate_entry(entry: &SeedEntry, options: &ApplyOptions<'_>) -> Result<()> {
149    let uri = SecretUri::parse(&entry.uri)?;
150
151    if let Some(reqs) = options.requirements {
152        #[cfg(feature = "schema-validate")]
153        if options.validate_schema {
154            if let Some(req) = find_requirement(&uri, reqs) {
155                if let (SecretFormat::Json, Some(schema), SeedValue::Json { json }) =
156                    (&entry.format, &req.schema, &entry.value)
157                {
158                    validate_json_schema(json, schema)?;
159                }
160            }
161        }
162        #[cfg(not(feature = "schema-validate"))]
163        let _ = find_requirement(&uri, reqs);
164    }
165
166    Ok(())
167}
168
169fn find_requirement<'a>(
170    uri: &SecretUri,
171    requirements: &'a [SecretRequirement],
172) -> Option<&'a SecretRequirement> {
173    let key = format!("{}/{}", uri.category(), uri.name());
174    requirements
175        .iter()
176        .find(|req| req.key.as_str() == key && scopes_match(uri.scope(), req.scope.as_ref()))
177}
178
179fn scopes_match(uri_scope: &greentic_secrets_spec::Scope, req_scope: Option<&SecretScope>) -> bool {
180    let Some(req_scope) = req_scope else {
181        return true;
182    };
183    uri_scope.env() == req_scope.env
184        && uri_scope.tenant() == req_scope.tenant
185        && uri_scope.team().map(|t| t.to_string()) == req_scope.team
186}
187
188#[cfg(feature = "schema-validate")]
189fn validate_json_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Result<()> {
190    let compiled = JSONSchema::compile(schema)
191        .map_err(|err| Error::Invalid("schema".into(), err.to_string()))?;
192
193    if let Err(errors) = compiled.validate(value) {
194        let messages: Vec<String> = errors.map(|err| err.to_string()).collect();
195        return Err(Error::Invalid("json".into(), messages.join("; ")));
196    }
197    Ok(())
198}
199
200fn format_to_content_type(format: SecretFormat) -> ContentType {
201    match format {
202        SecretFormat::Text => ContentType::Text,
203        SecretFormat::Json => ContentType::Json,
204        SecretFormat::Bytes => ContentType::Binary,
205    }
206}
207
208/// Adapter that applies seeds against a broker-backed store.
209pub struct BrokerStore<B, P>
210where
211    B: SecretsBackend,
212    P: KeyProvider,
213{
214    broker: Arc<Mutex<SecretsBroker<B, P>>>,
215}
216
217impl<B, P> BrokerStore<B, P>
218where
219    B: SecretsBackend,
220    P: KeyProvider,
221{
222    pub fn new(broker: SecretsBroker<B, P>) -> Self {
223        Self {
224            broker: Arc::new(Mutex::new(broker)),
225        }
226    }
227}
228
229#[async_trait]
230impl<B, P> SecretsStore for BrokerStore<B, P>
231where
232    B: SecretsBackend + Send + Sync + 'static,
233    P: KeyProvider + Send + Sync + 'static,
234{
235    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
236        let uri = SecretUri::parse(uri)?;
237        let mut broker = self.broker.lock().unwrap();
238        let mut meta = SecretMeta::new(
239            uri.clone(),
240            Visibility::Team,
241            format_to_content_type(format),
242        );
243        meta.description = None;
244        broker.put_secret(meta, bytes)?;
245        Ok(())
246    }
247
248    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
249        let uri = SecretUri::parse(uri)?;
250        let mut broker = self.broker.lock().unwrap();
251        let secret = broker
252            .get_secret(&uri)
253            .map_err(|err| Error::Backend(err.to_string()))?
254            .ok_or_else(|| Error::NotFound {
255                entity: uri.to_string(),
256            })?;
257        Ok(secret.payload)
258    }
259}
260
261/// HTTP store adapter for talking to the broker service.
262pub struct HttpStore {
263    client: Client,
264    base_url: String,
265    token: Option<String>,
266}
267
268impl HttpStore {
269    pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
270        Self::with_client(Client::new(), base_url, token)
271    }
272
273    pub fn with_client(client: Client, base_url: impl Into<String>, token: Option<String>) -> Self {
274        Self {
275            client,
276            base_url: base_url.into().trim_end_matches('/').to_string(),
277            token,
278        }
279    }
280}
281
282#[derive(serde::Serialize)]
283struct PutBody {
284    visibility: Visibility,
285    content_type: ContentType,
286    #[serde(default)]
287    encoding: ValueEncoding,
288    #[serde(default)]
289    description: Option<String>,
290    value: String,
291}
292
293#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
294#[serde(rename_all = "lowercase")]
295enum ValueEncoding {
296    Utf8,
297    Base64,
298}
299
300#[derive(serde::Deserialize)]
301struct GetResponse {
302    encoding: ValueEncoding,
303    value: String,
304}
305
306#[async_trait]
307impl SecretsStore for HttpStore {
308    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
309        let uri = SecretUri::parse(uri)?;
310        let path = match uri.scope().team() {
311            Some(team) => format!(
312                "{}/v1/{}/{}/{}/{}/{}",
313                self.base_url,
314                uri.scope().env(),
315                uri.scope().tenant(),
316                team,
317                uri.category(),
318                uri.name()
319            ),
320            None => format!(
321                "{}/v1/{}/{}/{}/{}",
322                self.base_url,
323                uri.scope().env(),
324                uri.scope().tenant(),
325                uri.category(),
326                uri.name()
327            ),
328        };
329
330        let encoding = match format {
331            SecretFormat::Text | SecretFormat::Json => ValueEncoding::Utf8,
332            SecretFormat::Bytes => ValueEncoding::Base64,
333        };
334        let payload = PutBody {
335            visibility: Visibility::Team,
336            content_type: format_to_content_type(format),
337            encoding: encoding.clone(),
338            description: None,
339            value: match encoding {
340                ValueEncoding::Utf8 => String::from_utf8(bytes.to_vec())
341                    .map_err(|err| Error::Invalid("utf8".into(), err.to_string()))?,
342                ValueEncoding::Base64 => STANDARD.encode(bytes),
343            },
344        };
345
346        let mut req = self.client.put(path).json(&payload);
347        if let Some(token) = &self.token {
348            req = req.bearer_auth(token);
349        }
350        let resp = req
351            .send()
352            .await
353            .map_err(|err| Error::Backend(err.to_string()))?;
354        if !resp.status().is_success() {
355            return Err(Error::Backend(format!("broker returned {}", resp.status())));
356        }
357        Ok(())
358    }
359
360    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
361        let uri = SecretUri::parse(uri)?;
362        let path = match uri.scope().team() {
363            Some(team) => format!(
364                "{}/v1/{}/{}/{}/{}/{}",
365                self.base_url,
366                uri.scope().env(),
367                uri.scope().tenant(),
368                team,
369                uri.category(),
370                uri.name()
371            ),
372            None => format!(
373                "{}/v1/{}/{}/{}/{}",
374                self.base_url,
375                uri.scope().env(),
376                uri.scope().tenant(),
377                uri.category(),
378                uri.name()
379            ),
380        };
381
382        let mut req = self.client.get(path);
383        if let Some(token) = &self.token {
384            req = req.bearer_auth(token);
385        }
386        let resp = req
387            .send()
388            .await
389            .map_err(|err| Error::Backend(err.to_string()))?;
390        if !resp.status().is_success() {
391            return Err(Error::Backend(format!("broker returned {}", resp.status())));
392        }
393        let body: GetResponse = resp
394            .json()
395            .await
396            .map_err(|err| Error::Backend(err.to_string()))?;
397        let bytes = match body.encoding {
398            ValueEncoding::Utf8 => Ok(body.value.into_bytes()),
399            ValueEncoding::Base64 => STANDARD
400                .decode(body.value.as_bytes())
401                .map_err(|err| Error::Invalid("base64".into(), err.to_string())),
402        }?;
403        Ok(bytes)
404    }
405}
406
407/// Convenience dev store backed by the dev provider.
408#[cfg(feature = "dev-store")]
409pub struct DevStore {
410    inner: BrokerStore<Box<dyn SecretsBackend>, Box<dyn KeyProvider>>,
411}
412
413#[cfg(feature = "dev-store")]
414impl DevStore {
415    /// Open the default dev store using the dev provider's environment/path resolution.
416    pub fn open_default() -> Result<Self> {
417        use greentic_secrets_provider_dev::{DevBackend, DevKeyProvider};
418
419        let backend = DevBackend::from_env().map_err(|err| Error::Backend(err.to_string()))?;
420        let key_provider: Box<dyn KeyProvider> = Box::new(DevKeyProvider::from_env());
421        let crypto = EnvelopeService::from_env(key_provider)?;
422        let broker = SecretsBroker::new(Box::new(backend) as Box<dyn SecretsBackend>, crypto);
423        Ok(Self {
424            inner: BrokerStore::new(broker),
425        })
426    }
427
428    /// Open a dev store with a specific persistence path.
429    pub fn with_path(path: impl Into<std::path::PathBuf>) -> Result<Self> {
430        use greentic_secrets_provider_dev::{DevBackend, DevKeyProvider};
431
432        let backend = DevBackend::with_persistence(path.into())
433            .map_err(|err| Error::Backend(err.to_string()))?;
434        let key_provider: Box<dyn KeyProvider> = Box::new(DevKeyProvider::from_env());
435        let crypto = EnvelopeService::from_env(key_provider)?;
436        let broker = SecretsBroker::new(Box::new(backend) as Box<dyn SecretsBackend>, crypto);
437        Ok(Self {
438            inner: BrokerStore::new(broker),
439        })
440    }
441}
442
443#[cfg(feature = "dev-store")]
444#[async_trait]
445impl SecretsStore for DevStore {
446    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
447        self.inner.put(uri, format, bytes).await
448    }
449
450    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
451        self.inner.get(uri).await
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use greentic_secrets_spec::SeedValue;
459    use tempfile::tempdir;
460
461    #[test]
462    fn resolve_uri_formats_placeholder() {
463        let ctx = DevContext::new("dev", "acme", None);
464        let mut req = SecretRequirement::default();
465        req.key = greentic_types::secrets::SecretKey::parse("configs/db").unwrap();
466        req.required = true;
467        req.scope = Some(SecretScope {
468            env: "dev".into(),
469            tenant: "acme".into(),
470            team: None,
471        });
472        req.format = Some(SecretFormat::Text);
473        let uri = resolve_uri(&ctx, &req);
474        assert_eq!(uri, "secrets://dev/acme/_/configs/db");
475    }
476
477    #[tokio::test]
478    #[cfg(feature = "dev-store")]
479    async fn apply_seed_roundtrip_dev_store() {
480        let dir = tempdir().unwrap();
481        let path = dir.path().join(".dev.secrets.env");
482        let store = DevStore::with_path(path).unwrap();
483
484        let seed = SeedDoc {
485            entries: vec![SeedEntry {
486                uri: "secrets://dev/acme/_/configs/db".into(),
487                format: SecretFormat::Text,
488                description: Some("db".into()),
489                value: SeedValue::Text {
490                    text: "secret".into(),
491                },
492            }],
493        };
494
495        let report = apply_seed(&store, &seed, ApplyOptions::default()).await;
496        assert_eq!(report.ok, 1);
497        assert!(report.failed.is_empty());
498
499        let fetched = store.get("secrets://dev/acme/_/configs/db").await.unwrap();
500        assert_eq!(fetched, b"secret".to_vec());
501    }
502}