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