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