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