Skip to main content

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::validator_for;
14use reqwest::Client;
15use std::collections::HashMap;
16use std::sync::{Arc, Mutex};
17
18/// Minimal dev context used for resolving requirement keys into URIs.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DevContext {
21    pub env: String,
22    pub tenant: String,
23    pub team: Option<String>,
24}
25
26impl DevContext {
27    pub fn new(env: impl Into<String>, tenant: impl Into<String>, team: Option<String>) -> Self {
28        Self {
29            env: env.into(),
30            tenant: tenant.into(),
31            team,
32        }
33    }
34}
35
36/// Resolve a requirement into a concrete URI for dev flows.
37pub fn resolve_uri(ctx: &DevContext, req: &SecretRequirement) -> String {
38    resolve_uri_with_category(ctx, req, "configs")
39}
40
41pub fn resolve_uri_with_category(
42    ctx: &DevContext,
43    req: &SecretRequirement,
44    default_category: &str,
45) -> String {
46    // Route the team through the canonical normalizer so `default`/empty/None
47    // all collapse to the `_` placeholder (the "`_` everywhere" rule).
48    let normalized = greentic_secrets_spec::normalize_team(ctx.team.as_deref());
49    let team = normalized.as_deref().unwrap_or("_");
50    let key = normalize_req_key(req.key.as_str(), default_category);
51    format!("secrets://{}/{}/{}/{}", ctx.env, ctx.tenant, team, key)
52}
53
54/// Normalized seed entry with bytes payload.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct NormalizedSeedEntry {
57    pub uri: String,
58    pub format: SecretFormat,
59    pub bytes: Vec<u8>,
60    pub description: Option<String>,
61}
62
63/// Errors encountered while applying a seed entry.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ApplyFailure {
66    pub uri: String,
67    pub error: String,
68}
69
70/// Summary report from seed application.
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct ApplyReport {
73    pub ok: usize,
74    pub failed: Vec<ApplyFailure>,
75}
76
77/// Options for applying seeds.
78#[derive(Default)]
79pub struct ApplyOptions<'a> {
80    pub requirements: Option<&'a [SecretRequirement]>,
81    pub validate_schema: bool,
82}
83
84#[async_trait]
85pub trait SecretsStore: Send + Sync {
86    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()>;
87    async fn get(&self, uri: &str) -> Result<Vec<u8>>;
88}
89
90/// Apply all entries in a seed document to the provided store.
91pub async fn apply_seed<S: SecretsStore + ?Sized>(
92    store: &S,
93    seed: &SeedDoc,
94    options: ApplyOptions<'_>,
95) -> ApplyReport {
96    let mut ok = 0usize;
97    let mut failed = Vec::new();
98    let requirement_lookup = options.requirements.map(RequirementLookup::new);
99
100    for entry in &seed.entries {
101        if let Err(err) = validate_entry(entry, &options, requirement_lookup.as_ref()) {
102            failed.push(ApplyFailure {
103                uri: entry.uri.clone(),
104                error: err.to_string(),
105            });
106            continue;
107        }
108
109        match normalize_seed_entry(entry) {
110            Ok(normalized) => {
111                if let Err(err) = store
112                    .put(&normalized.uri, normalized.format, &normalized.bytes)
113                    .await
114                {
115                    failed.push(ApplyFailure {
116                        uri: normalized.uri,
117                        error: err.to_string(),
118                    });
119                } else {
120                    ok += 1;
121                }
122            }
123            Err(err) => failed.push(ApplyFailure {
124                uri: entry.uri.clone(),
125                error: err.to_string(),
126            }),
127        }
128    }
129
130    ApplyReport { ok, failed }
131}
132
133fn normalize_seed_entry(entry: &SeedEntry) -> Result<NormalizedSeedEntry> {
134    let bytes = match (&entry.format, &entry.value) {
135        (SecretFormat::Text, SeedValue::Text { text }) => Ok(text.as_bytes().to_vec()),
136        (SecretFormat::Json, SeedValue::Json { json }) => {
137            serde_json::to_vec(json).map_err(|err| Error::Invalid("json".into(), err.to_string()))
138        }
139        (SecretFormat::Bytes, SeedValue::BytesB64 { bytes_b64 }) => STANDARD
140            .decode(bytes_b64.as_bytes())
141            .map_err(|err| Error::Invalid("bytes_b64".into(), err.to_string())),
142        _ => Err(Error::Invalid(
143            "seed".into(),
144            "format/value mismatch".into(),
145        )),
146    }?;
147
148    Ok(NormalizedSeedEntry {
149        uri: entry.uri.clone(),
150        format: entry.format.clone(),
151        bytes,
152        description: entry.description.clone(),
153    })
154}
155
156fn validate_entry(
157    entry: &SeedEntry,
158    options: &ApplyOptions<'_>,
159    requirement_lookup: Option<&RequirementLookup<'_>>,
160) -> Result<()> {
161    let uri = SecretUri::parse(&entry.uri)?;
162
163    if let Some(reqs) = options.requirements {
164        #[cfg(feature = "schema-validate")]
165        if options.validate_schema
166            && let Some(req) = find_requirement(&uri, reqs, requirement_lookup)
167            && let (SecretFormat::Json, Some(schema), SeedValue::Json { json }) =
168                (&entry.format, &req.schema, &entry.value)
169        {
170            validate_json_schema(json, schema)?;
171        }
172        #[cfg(not(feature = "schema-validate"))]
173        let _ = find_requirement(&uri, reqs, requirement_lookup);
174    }
175
176    Ok(())
177}
178
179#[derive(Clone, Copy)]
180struct IndexedRequirement<'a> {
181    position: usize,
182    requirement: &'a SecretRequirement,
183}
184
185struct RequirementLookup<'a> {
186    explicit: HashMap<String, Vec<IndexedRequirement<'a>>>,
187    implicit: HashMap<String, Vec<IndexedRequirement<'a>>>,
188}
189
190impl<'a> RequirementLookup<'a> {
191    fn new(requirements: &'a [SecretRequirement]) -> Self {
192        let mut explicit = HashMap::new();
193        let mut implicit = HashMap::new();
194
195        for (position, requirement) in requirements.iter().enumerate() {
196            let entry = IndexedRequirement {
197                position,
198                requirement,
199            };
200            let key = requirement.key.as_str().to_ascii_lowercase();
201            if key.contains('/') {
202                explicit.entry(key).or_insert_with(Vec::new).push(entry);
203            } else {
204                implicit.entry(key).or_insert_with(Vec::new).push(entry);
205            }
206        }
207
208        Self { explicit, implicit }
209    }
210
211    fn find(&self, uri: &SecretUri) -> Option<&'a SecretRequirement> {
212        let explicit = self
213            .explicit
214            .get(&format!("{}/{}", uri.category(), uri.name()))
215            .into_iter()
216            .flatten();
217        let implicit = self.implicit.get(uri.name()).into_iter().flatten();
218
219        explicit
220            .chain(implicit)
221            .filter(|entry| scopes_match(uri.scope(), entry.requirement.scope.as_ref()))
222            .min_by_key(|entry| entry.position)
223            .map(|entry| entry.requirement)
224    }
225}
226
227fn find_requirement<'a>(
228    uri: &SecretUri,
229    requirements: &'a [SecretRequirement],
230    requirement_lookup: Option<&RequirementLookup<'a>>,
231) -> Option<&'a SecretRequirement> {
232    if let Some(lookup) = requirement_lookup {
233        return lookup.find(uri);
234    }
235
236    let key = format!("{}/{}", uri.category(), uri.name());
237    requirements.iter().find(|req| {
238        normalize_req_key(req.key.as_str(), uri.category()) == key
239            && scopes_match(uri.scope(), req.scope.as_ref())
240    })
241}
242
243fn normalize_req_key(key: &str, default_category: &str) -> String {
244    let normalized = key.to_ascii_lowercase();
245    if normalized.contains('/') {
246        normalized
247    } else {
248        format!("{default_category}/{normalized}")
249    }
250}
251
252fn scopes_match(uri_scope: &greentic_secrets_spec::Scope, req_scope: Option<&SecretScope>) -> bool {
253    let Some(req_scope) = req_scope else {
254        return true;
255    };
256    uri_scope.env() == req_scope.env
257        && uri_scope.tenant() == req_scope.tenant
258        && uri_scope.team() == req_scope.team.as_deref()
259}
260
261#[cfg(feature = "schema-validate")]
262fn validate_json_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Result<()> {
263    let compiled =
264        validator_for(schema).map_err(|err| Error::Invalid("schema".into(), err.to_string()))?;
265
266    let messages: Vec<String> = compiled
267        .iter_errors(value)
268        .map(|err| err.to_string())
269        .collect();
270    if !messages.is_empty() {
271        return Err(Error::Invalid("json".into(), messages.join("; ")));
272    }
273    Ok(())
274}
275
276fn format_to_content_type(format: SecretFormat) -> ContentType {
277    match format {
278        SecretFormat::Text => ContentType::Text,
279        SecretFormat::Json => ContentType::Json,
280        SecretFormat::Bytes => ContentType::Binary,
281    }
282}
283
284/// Adapter that applies seeds against a broker-backed store.
285pub struct BrokerStore<B, P>
286where
287    B: SecretsBackend,
288    P: KeyProvider,
289{
290    broker: Arc<Mutex<SecretsBroker<B, P>>>,
291}
292
293impl<B, P> BrokerStore<B, P>
294where
295    B: SecretsBackend,
296    P: KeyProvider,
297{
298    pub fn new(broker: SecretsBroker<B, P>) -> Self {
299        Self {
300            broker: Arc::new(Mutex::new(broker)),
301        }
302    }
303}
304
305#[async_trait]
306impl<B, P> SecretsStore for BrokerStore<B, P>
307where
308    B: SecretsBackend + Send + Sync + 'static,
309    P: KeyProvider + Send + Sync + 'static,
310{
311    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
312        let uri = SecretUri::parse(uri)?;
313        let mut broker = self.broker.lock().unwrap();
314        let mut meta = SecretMeta::new(
315            uri.clone(),
316            Visibility::Team,
317            format_to_content_type(format),
318        );
319        meta.description = None;
320        broker.put_secret(meta, bytes)?;
321        Ok(())
322    }
323
324    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
325        let uri = SecretUri::parse(uri)?;
326        let mut broker = self.broker.lock().unwrap();
327        let secret = broker
328            .get_secret(&uri)
329            .map_err(|err| Error::Backend(err.to_string()))?
330            .ok_or_else(|| Error::NotFound {
331                entity: uri.to_string(),
332            })?;
333        Ok(secret.payload)
334    }
335}
336
337/// HTTP store adapter for talking to the broker service.
338pub struct HttpStore {
339    client: Client,
340    base_url: String,
341    token: Option<String>,
342}
343
344impl HttpStore {
345    pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
346        Self::with_client(Client::new(), base_url, token)
347    }
348
349    pub fn with_client(client: Client, base_url: impl Into<String>, token: Option<String>) -> Self {
350        Self {
351            client,
352            base_url: base_url.into().trim_end_matches('/').to_string(),
353            token,
354        }
355    }
356}
357
358#[derive(serde::Serialize)]
359struct PutBody {
360    visibility: Visibility,
361    content_type: ContentType,
362    #[serde(default)]
363    encoding: ValueEncoding,
364    #[serde(default)]
365    description: Option<String>,
366    value: String,
367}
368
369#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
370#[serde(rename_all = "lowercase")]
371enum ValueEncoding {
372    Utf8,
373    Base64,
374}
375
376#[derive(serde::Deserialize)]
377struct GetResponse {
378    encoding: ValueEncoding,
379    value: String,
380}
381
382#[async_trait]
383impl SecretsStore for HttpStore {
384    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
385        let uri = SecretUri::parse(uri)?;
386        let path = match uri.scope().team() {
387            Some(team) => format!(
388                "{}/v1/{}/{}/{}/{}/{}",
389                self.base_url,
390                uri.scope().env(),
391                uri.scope().tenant(),
392                team,
393                uri.category(),
394                uri.name()
395            ),
396            None => format!(
397                "{}/v1/{}/{}/{}/{}",
398                self.base_url,
399                uri.scope().env(),
400                uri.scope().tenant(),
401                uri.category(),
402                uri.name()
403            ),
404        };
405
406        let encoding = match format {
407            SecretFormat::Text | SecretFormat::Json => ValueEncoding::Utf8,
408            SecretFormat::Bytes => ValueEncoding::Base64,
409        };
410        let payload = PutBody {
411            visibility: Visibility::Team,
412            content_type: format_to_content_type(format),
413            encoding: encoding.clone(),
414            description: None,
415            value: match encoding {
416                ValueEncoding::Utf8 => String::from_utf8(bytes.to_vec())
417                    .map_err(|err| Error::Invalid("utf8".into(), err.to_string()))?,
418                ValueEncoding::Base64 => STANDARD.encode(bytes),
419            },
420        };
421
422        let mut req = self.client.put(path).json(&payload);
423        if let Some(token) = &self.token {
424            req = req.bearer_auth(token);
425        }
426        let resp = req
427            .send()
428            .await
429            .map_err(|err| Error::Backend(err.to_string()))?;
430        if !resp.status().is_success() {
431            return Err(Error::Backend(format!("broker returned {}", resp.status())));
432        }
433        Ok(())
434    }
435
436    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
437        let uri = SecretUri::parse(uri)?;
438        let path = match uri.scope().team() {
439            Some(team) => format!(
440                "{}/v1/{}/{}/{}/{}/{}",
441                self.base_url,
442                uri.scope().env(),
443                uri.scope().tenant(),
444                team,
445                uri.category(),
446                uri.name()
447            ),
448            None => format!(
449                "{}/v1/{}/{}/{}/{}",
450                self.base_url,
451                uri.scope().env(),
452                uri.scope().tenant(),
453                uri.category(),
454                uri.name()
455            ),
456        };
457
458        let mut req = self.client.get(path);
459        if let Some(token) = &self.token {
460            req = req.bearer_auth(token);
461        }
462        let resp = req
463            .send()
464            .await
465            .map_err(|err| Error::Backend(err.to_string()))?;
466        if !resp.status().is_success() {
467            return Err(Error::Backend(format!("broker returned {}", resp.status())));
468        }
469        let body: GetResponse = resp
470            .json()
471            .await
472            .map_err(|err| Error::Backend(err.to_string()))?;
473        let bytes = match body.encoding {
474            ValueEncoding::Utf8 => Ok(body.value.into_bytes()),
475            ValueEncoding::Base64 => STANDARD
476                .decode(body.value.as_bytes())
477                .map_err(|err| Error::Invalid("base64".into(), err.to_string())),
478        }?;
479        Ok(bytes)
480    }
481}
482
483/// Convenience dev store backed by the dev provider.
484#[cfg(feature = "dev-store")]
485pub struct DevStore {
486    inner: BrokerStore<Box<dyn SecretsBackend>, Box<dyn KeyProvider>>,
487}
488
489#[cfg(feature = "dev-store")]
490impl DevStore {
491    /// Open the default dev store using the dev provider's environment/path resolution.
492    pub fn open_default() -> Result<Self> {
493        use greentic_secrets_provider_dev::{DevBackend, DevKeyProvider};
494
495        let backend = DevBackend::from_env().map_err(|err| Error::Backend(err.to_string()))?;
496        let key_provider: Box<dyn KeyProvider> = Box::new(DevKeyProvider::from_env());
497        let crypto = EnvelopeService::from_env(key_provider)?;
498        let broker = SecretsBroker::new(Box::new(backend) as Box<dyn SecretsBackend>, crypto);
499        Ok(Self {
500            inner: BrokerStore::new(broker),
501        })
502    }
503
504    /// Open a dev store with a specific persistence path.
505    pub fn with_path(path: impl Into<std::path::PathBuf>) -> Result<Self> {
506        use greentic_secrets_provider_dev::{DevBackend, DevKeyProvider};
507
508        let backend = DevBackend::with_persistence(path.into())
509            .map_err(|err| Error::Backend(err.to_string()))?;
510        let key_provider: Box<dyn KeyProvider> = Box::new(DevKeyProvider::from_env());
511        let crypto = EnvelopeService::from_env(key_provider)?;
512        let broker = SecretsBroker::new(Box::new(backend) as Box<dyn SecretsBackend>, crypto);
513        Ok(Self {
514            inner: BrokerStore::new(broker),
515        })
516    }
517}
518
519#[cfg(feature = "dev-store")]
520#[async_trait]
521impl SecretsStore for DevStore {
522    async fn put(&self, uri: &str, format: SecretFormat, bytes: &[u8]) -> Result<()> {
523        self.inner.put(uri, format, bytes).await
524    }
525
526    async fn get(&self, uri: &str) -> Result<Vec<u8>> {
527        self.inner.get(uri).await
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use greentic_secrets_spec::SeedValue;
535    use reqwest::Client;
536    use tempfile::tempdir;
537
538    #[test]
539    fn resolve_uri_formats_placeholder() {
540        let ctx = DevContext::new("dev", "acme", None);
541        let mut req = SecretRequirement::default();
542        req.key = greentic_types::secrets::SecretKey::parse("configs/db").unwrap();
543        req.required = true;
544        req.scope = Some(SecretScope {
545            env: "dev".into(),
546            tenant: "acme".into(),
547            team: None,
548        });
549        req.format = Some(SecretFormat::Text);
550        let uri = resolve_uri(&ctx, &req);
551        assert_eq!(uri, "secrets://dev/acme/_/configs/db");
552    }
553
554    #[test]
555    fn resolve_uri_respects_custom_category() {
556        let ctx = DevContext::new("dev", "acme", None);
557        let mut req = SecretRequirement::default();
558        req.key = greentic_types::secrets::SecretKey::parse("db").unwrap();
559        let uri = resolve_uri_with_category(&ctx, &req, "greentic.secrets.fixture");
560        assert_eq!(uri, "secrets://dev/acme/_/greentic.secrets.fixture/db");
561    }
562
563    #[test]
564    fn resolve_uri_collapses_default_team_to_placeholder() {
565        let ctx = DevContext::new("dev", "acme", Some("default".to_string()));
566        let mut req = SecretRequirement::default();
567        req.key = greentic_types::secrets::SecretKey::parse("configs/db").unwrap();
568        let uri = resolve_uri(&ctx, &req);
569        assert_eq!(uri, "secrets://dev/acme/_/configs/db");
570    }
571
572    #[tokio::test]
573    #[cfg(feature = "dev-store")]
574    async fn apply_seed_roundtrip_dev_store() {
575        let dir = tempdir().unwrap();
576        let path = dir.path().join(".dev.secrets.env");
577        let store = DevStore::with_path(path).unwrap();
578
579        let seed = SeedDoc {
580            entries: vec![SeedEntry {
581                uri: "secrets://dev/acme/_/configs/db".into(),
582                format: SecretFormat::Text,
583                description: Some("db".into()),
584                value: SeedValue::Text {
585                    text: "secret".into(),
586                },
587            }],
588        };
589
590        let report = apply_seed(&store, &seed, ApplyOptions::default()).await;
591        assert_eq!(report.ok, 1);
592        assert!(report.failed.is_empty());
593
594        let fetched = store.get("secrets://dev/acme/_/configs/db").await.unwrap();
595        assert_eq!(fetched, b"secret".to_vec());
596    }
597
598    #[test]
599    fn normalize_seed_entry_supports_json_and_rejects_mismatches() {
600        let json_entry = SeedEntry {
601            uri: "secrets://dev/acme/_/configs/app".into(),
602            format: SecretFormat::Json,
603            description: Some("json".into()),
604            value: SeedValue::Json {
605                json: serde_json::json!({"enabled": true}),
606            },
607        };
608        let normalized = normalize_seed_entry(&json_entry).expect("normalized");
609        assert_eq!(normalized.bytes, br#"{"enabled":true}"#);
610
611        let bad_entry = SeedEntry {
612            uri: "secrets://dev/acme/_/configs/app".into(),
613            format: SecretFormat::Bytes,
614            description: None,
615            value: SeedValue::Text {
616                text: "wrong".into(),
617            },
618        };
619        assert!(normalize_seed_entry(&bad_entry).is_err());
620    }
621
622    #[test]
623    fn find_requirement_matches_normalized_key_and_scope() {
624        let uri = SecretUri::parse("secrets://dev/acme/core/configs/db").expect("uri");
625        let mut req = SecretRequirement::default();
626        req.key = "DB".into();
627        req.scope = Some(SecretScope {
628            env: "dev".into(),
629            tenant: "acme".into(),
630            team: Some("core".into()),
631        });
632        let requirements = [req];
633
634        let found = find_requirement(&uri, &requirements, None).expect("requirement");
635        assert_eq!(found.key.as_str(), "DB");
636    }
637
638    #[test]
639    fn scopes_match_requires_team_when_present() {
640        let uri = SecretUri::parse("secrets://dev/acme/core/configs/db").expect("uri");
641        let matching = SecretScope {
642            env: "dev".into(),
643            tenant: "acme".into(),
644            team: Some("core".into()),
645        };
646        let mismatched = SecretScope {
647            env: "dev".into(),
648            tenant: "acme".into(),
649            team: Some("other".into()),
650        };
651
652        assert!(scopes_match(uri.scope(), Some(&matching)));
653        assert!(!scopes_match(uri.scope(), Some(&mismatched)));
654        assert!(scopes_match(uri.scope(), None));
655    }
656
657    #[test]
658    fn http_store_trims_trailing_slashes() {
659        let store = HttpStore::with_client(
660            Client::new(),
661            "https://broker.example.test/",
662            Some("token".into()),
663        );
664        assert_eq!(store.base_url, "https://broker.example.test");
665        assert_eq!(store.token.as_deref(), Some("token"));
666    }
667}