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