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#[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
36pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ApplyFailure {
63 pub uri: String,
64 pub error: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct ApplyReport {
70 pub ok: usize,
71 pub failed: Vec<ApplyFailure>,
72}
73
74#[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
87pub 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
281pub 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
334pub 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#[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 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 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}