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