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