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#[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
35pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ApplyFailure {
62 pub uri: String,
63 pub error: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct ApplyReport {
69 pub ok: usize,
70 pub failed: Vec<ApplyFailure>,
71}
72
73#[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
86pub 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
222pub 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
275pub 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#[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 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 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}