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