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