1use std::collections::{BTreeMap, BTreeSet};
17use std::time::{Duration, Instant};
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::{Error, Result};
22use crate::org::OrgId;
23use crate::secrets::Secrets;
24use crate::spec::{ComposeFile, SecretDef};
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SecretBinding {
29 pub name: String,
31 pub driver: String,
33 pub version: u64,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38 pub owned: bool,
39}
40
41impl SecretBinding {
42 pub fn is_driver_backed(&self) -> bool {
46 self.driver != crate::secrets::local::DRIVER
47 }
48}
49
50pub fn owned_name(stack: &str, key: &str) -> Result<String> {
52 let n = format!("{stack}_{key}");
53 crate::secrets::validate_name(&n)
54 .map_err(|e| Error::invalid(format!("secret {key:?} of stack {stack}: {e}")))?;
55 Ok(n)
56}
57
58pub fn used_keys(file: &ComposeFile) -> BTreeSet<String> {
60 file.services
61 .values()
62 .flat_map(|s| s.secret_keys())
63 .map(String::from)
64 .collect()
65}
66
67fn declared<'a>(file: &'a ComposeFile, key: &str) -> Result<&'a SecretDef> {
68 file.secrets.get(key).ok_or_else(|| {
69 Error::invalid(format!(
70 "secret {key:?} is not declared under top-level secrets"
71 ))
72 })
73}
74
75pub fn bind(
81 secrets: &Secrets,
82 org: &OrgId,
83 stack: &str,
84 file: &ComposeFile,
85 given: &BTreeMap<String, Vec<u8>>,
86 dry_run: bool,
87) -> Result<BTreeMap<String, SecretBinding>> {
88 let mut out = BTreeMap::new();
89 for key in used_keys(file) {
90 let def = declared(file, &key)?;
91 let b = if let Some(store) = def.store_name(&key) {
92 let m = secrets.inspect(org, store).map_err(|e| match e {
93 Error::NotFound(_) => Error::invalid(format!(
94 "secret {key:?}: external secret {store} does not exist in org {org}; create it with `isb secret create {store} --org {org}`"
95 )),
96 e => e,
97 })?;
98 SecretBinding {
99 name: store.to_string(),
100 driver: m.driver,
101 version: m.version,
102 owned: false,
103 }
104 } else if let Some(driver) = &def.driver {
105 let r = def.name.clone().unwrap_or_default();
106 let version = secrets
107 .version_in(driver, org, &r)
108 .map_err(|e| Error::invalid(format!("secret {key:?} ({driver} {r}): {e}")))?;
109 SecretBinding {
110 name: r,
111 driver: driver.clone(),
112 version,
113 owned: false,
114 }
115 } else {
116 let value = if let Some(text) = &def.age {
117 secrets
118 .decrypt_inline(text)
119 .map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?
120 } else {
121 given.get(&key).cloned().ok_or_else(|| {
122 Error::invalid(format!("no value for secret {key:?}: pass it in `secrets`"))
123 })?
124 };
125 let name = owned_name(stack, &key)?;
126 let m = if dry_run {
127 would_put(secrets, org, &name, &value)?
128 } else {
129 let m = secrets.put(org, &name, &value)?;
130 (m.driver, m.version)
131 };
132 SecretBinding {
133 name,
134 driver: m.0,
135 version: m.1,
136 owned: true,
137 }
138 };
139 out.insert(key, b);
140 }
141 Ok(out)
142}
143
144fn would_put(secrets: &Secrets, org: &OrgId, name: &str, value: &[u8]) -> Result<(String, u64)> {
146 match secrets.get(org, name) {
147 Ok((v, m)) if v == value => Ok((m.driver, m.version)),
148 Ok((_, m)) => Ok((m.driver, m.version + 1)),
149 Err(Error::NotFound(_)) => Ok((crate::secrets::local::DRIVER.into(), 1)),
150 Err(e) => Err(e),
151 }
152}
153
154impl SecretBinding {
155 pub fn read(&self, secrets: &Secrets, org: &OrgId) -> Result<Vec<u8>> {
158 secrets
159 .get_in(&self.driver, org, &self.name)
160 .map(|(v, _)| v)
161 .map_err(|e| Error::invalid(format!("secret {}: {e}", self.name)))
162 }
163}
164
165pub fn values<'a>(
168 secrets: &Secrets,
169 org: &OrgId,
170 bindings: &BTreeMap<String, SecretBinding>,
171 keys: impl IntoIterator<Item = &'a str>,
172) -> Result<BTreeMap<String, Vec<u8>>> {
173 let mut out = BTreeMap::new();
174 for key in keys {
175 let b = bindings.get(key).ok_or_else(|| {
176 Error::invalid(format!(
177 "secret {key:?} is not bound in this deployment; deploy the stack again"
178 ))
179 })?;
180 out.insert(key.to_string(), b.read(secrets, org)?);
181 }
182 Ok(out)
183}
184
185pub fn resolve(
189 secrets: &Secrets,
190 org: &OrgId,
191 defs: &BTreeMap<String, SecretDef>,
192) -> Result<BTreeMap<String, Vec<u8>>> {
193 let mut out = BTreeMap::new();
194 for (key, def) in defs {
195 def.validate()
196 .map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?;
197 let v = if let Some(store) = def.store_name(key) {
198 secrets.get(org, store).map(|(v, _)| v)
199 } else if let Some(text) = &def.age {
200 secrets.decrypt_inline(text)
201 } else if let Some(driver) = &def.driver {
202 secrets
203 .get_in(driver, org, def.name.as_deref().unwrap_or_default())
204 .map(|(v, _)| v)
205 } else {
206 continue;
207 };
208 out.insert(
209 key.clone(),
210 v.map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?,
211 );
212 }
213 Ok(out)
214}
215
216pub type Refreshed = (Vec<(String, u64)>, Vec<String>);
219
220#[derive(Debug, Default)]
222pub struct RefreshSchedule {
223 next: BTreeMap<(String, String), Instant>,
224}
225
226impl RefreshSchedule {
227 pub fn due<'a>(
232 &mut self,
233 stacks: impl IntoIterator<Item = (&'a str, &'a super::StackDef)>,
234 now: Instant,
235 ) -> Vec<(String, String)> {
236 let mut seen = BTreeSet::new();
237 let mut out = Vec::new();
238 for (q, def) in stacks {
239 for (key, b) in &def.secrets {
240 if !b.is_driver_backed() {
241 continue;
242 }
243 let decl = def.file.secrets.get(key);
244 let every = decl
245 .map(SecretDef::refresh_interval)
246 .unwrap_or(crate::spec::DEFAULT_SECRET_REFRESH);
247 let id = (q.to_string(), key.clone());
248 seen.insert(id.clone());
249 let next = self.next.entry(id.clone()).or_insert(now + every);
250 if now >= *next {
251 *next = now + every;
252 out.push(id);
253 }
254 }
255 }
256 self.next.retain(|k, _| seen.contains(k));
258 out
259 }
260
261 pub fn reset(&mut self, q: &str, key: &str, every: Duration, now: Instant) {
263 self.next
264 .insert((q.to_string(), key.to_string()), now + every);
265 }
266}
267
268#[cfg(test)]
269pub(crate) mod tests_support {
270 use super::*;
271 use crate::secrets::{Driver, Keyring, LocalDriver, SecretMeta};
272 use std::sync::{Arc, Mutex};
273
274 pub(crate) struct Vault(pub Mutex<u64>);
276 impl Driver for Vault {
277 fn name(&self) -> &str {
278 "vault"
279 }
280 fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
281 let v = self.version(org, name)?;
282 Ok((format!("{name}@{v}").into_bytes(), v))
283 }
284 fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
285 if name.starts_with("op://") {
286 Ok(*self.0.lock().unwrap())
287 } else {
288 Err(crate::secrets::not_found(org, name))
289 }
290 }
291 fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
292 Err(crate::secrets::not_found(org, name))
293 }
294 fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
295 Ok(vec![])
296 }
297 }
298
299 pub(crate) fn store(dir: &std::path::Path) -> (Secrets, Arc<Vault>) {
300 let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
301 let vault = Arc::new(Vault(Mutex::new(3)));
302 let s = Secrets::new(LocalDriver::new(dir, Arc::new(k)))
303 .with_driver(vault.clone())
304 .unwrap();
305 (s, vault)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::tests_support::store;
312 use super::*;
313 use crate::secrets::Keyring;
314
315 fn file(y: &str) -> ComposeFile {
316 serde_yaml_ng::from_str(y).unwrap()
317 }
318
319 const FILE: &str = concat!(
320 "secrets:\n",
321 " db: {external: true, name: db.password}\n",
322 " tok: {environment: TOK}\n",
323 " cert: {file: ./cert.pem}\n",
324 " api: {driver: vault, name: 'op://v/api/key', refresh: 30m}\n",
325 " unused: {external: true}\n",
326 "services:\n",
327 " web:\n",
328 " image: docker:busybox\n",
329 " secrets: [db, cert]\n",
330 " environment: {TOKEN: {secret: tok}, API: {secret: api}, PLAIN: x}\n",
331 );
332
333 #[test]
334 fn binds_every_source_by_name_and_version() {
335 let dir = tempfile::tempdir().unwrap();
336 let (s, _) = store(dir.path());
337 let org = OrgId::default_org();
338 let f = file(FILE);
339 assert_eq!(
340 used_keys(&f).into_iter().collect::<Vec<_>>(),
341 ["api", "cert", "db", "tok"]
342 );
343 let given = BTreeMap::from([
344 ("tok".to_string(), b"t0k".to_vec()),
345 ("cert".to_string(), b"PEM".to_vec()),
346 ]);
347 let e = bind(&s, &org, "app", &f, &given, false).unwrap_err();
349 assert!(
350 e.to_string().contains("isb secret create db.password"),
351 "{e}"
352 );
353 s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
354 .unwrap();
355 let dry = bind(&s, &org, "app", &f, &given, true).unwrap();
357 assert_eq!(dry["tok"].version, 1);
358 assert!(s.inspect(&org, "app_tok").is_err());
359 let b = bind(&s, &org, "app", &f, &given, false).unwrap();
360 assert_eq!(dry, b);
361 assert_eq!(
362 b["db"],
363 SecretBinding {
364 name: "db.password".into(),
365 driver: "local".into(),
366 version: 1,
367 owned: false
368 }
369 );
370 assert_eq!(
371 (b["tok"].name.as_str(), b["tok"].version, b["tok"].owned),
372 ("app_tok", 1, true)
373 );
374 assert_eq!(b["cert"].name, "app_cert");
375 assert_eq!((b["api"].driver.as_str(), b["api"].version), ("vault", 3));
376 assert!(!b.contains_key("unused"));
377 let v = values(&s, &org, &b, ["tok", "db", "api"]).unwrap();
379 assert_eq!(v["tok"], b"t0k");
380 assert_eq!(v["db"], b"pw");
381 assert_eq!(v["api"], b"op://v/api/key@3");
382 assert!(values(&s, &org, &b, ["nope"]).is_err());
383 let again = bind(&s, &org, "app", &f, &given, false).unwrap();
385 assert_eq!(again["tok"].version, 1);
386 let mut given2 = given.clone();
387 given2.insert("tok".into(), b"new".to_vec());
388 assert_eq!(
389 bind(&s, &org, "app", &f, &given2, true).unwrap()["tok"].version,
390 2
391 );
392 assert_eq!(
393 bind(&s, &org, "app", &f, &given2, false).unwrap()["tok"].version,
394 2
395 );
396 let e = bind(&s, &org, "app", &f, &BTreeMap::new(), false).unwrap_err();
398 assert!(e.to_string().contains("no value for secret"), "{e}");
399 }
400
401 #[test]
402 fn inline_age_is_decrypted_and_stored() {
403 let dir = tempfile::tempdir().unwrap();
404 let (s, _) = store(dir.path());
405 let org = OrgId::new("alpha").unwrap();
406 let armored = s.encrypt_inline(b"inline-value").unwrap();
407 let mut f = file("services:\n web: {image: x, secrets: [k]}\n");
408 f.secrets.insert(
409 "k".into(),
410 SecretDef {
411 age: Some(armored.clone()),
412 ..Default::default()
413 },
414 );
415 let b = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap();
416 assert_eq!((b["k"].name.as_str(), b["k"].version), ("web_k", 1));
417 assert_eq!(s.get(&org, "web_k").unwrap().0, b"inline-value");
418 f.secrets.get_mut("k").unwrap().age = Some(s.encrypt_inline(b"inline-value").unwrap());
420 assert_eq!(
421 bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap()["k"].version,
422 1
423 );
424 let other = Keyring::new(age::x25519::Identity::generate(), vec![]);
426 let foreign = crate::secrets::encrypt_inline(b"x", other.recipients()).unwrap();
427 f.secrets.get_mut("k").unwrap().age = Some(foreign);
428 let e = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap_err();
429 assert!(e.to_string().contains("secret \"k\""), "{e}");
430 f.secrets.get_mut("k").unwrap().age = Some(armored);
432 let r = resolve(&s, &org, &f.secrets).unwrap();
433 assert_eq!(r["k"], b"inline-value");
434 }
435
436 #[test]
437 fn resolve_reads_store_backed_sources_only() {
438 let dir = tempfile::tempdir().unwrap();
439 let (s, _) = store(dir.path());
440 let org = OrgId::default_org();
441 s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
442 .unwrap();
443 let f = file(FILE);
444 let r = resolve(&s, &org, &f.secrets).unwrap_err();
445 assert!(r.to_string().contains("unused"), "{r}");
446 let mut defs = f.secrets.clone();
447 defs.remove("unused");
448 let r = resolve(&s, &org, &defs).unwrap();
449 assert_eq!(
450 r.keys().map(String::as_str).collect::<Vec<_>>(),
451 ["api", "db"]
452 );
453 assert_eq!(r["db"], b"pw");
454 }
455
456 #[test]
457 fn refresh_schedule() {
458 let mut def = super::super::StackDef {
459 name: "app".into(),
460 org: OrgId::default_org(),
461 file: file(FILE),
462 base_dir: "/".into(),
463 secrets: BTreeMap::new(),
464 force: BTreeMap::new(),
465 images: BTreeMap::new(),
466 deployed_at: 0,
467 deployed_by: String::new(),
468 previous: None,
469 };
470 let bind = |name: &str, driver: &str| SecretBinding {
471 name: name.into(),
472 driver: driver.into(),
473 version: 1,
474 owned: false,
475 };
476 def.secrets
477 .insert("db".into(), bind("db.password", "local"));
478 def.secrets
479 .insert("api".into(), bind("op://v/api/key", "vault"));
480 let mut f2 = def.file.clone();
482 f2.secrets.get_mut("api").unwrap().refresh = None;
483 let mut def2 = def.clone();
484 def2.name = "two".into();
485 def2.file = f2;
486
487 let mut sch = RefreshSchedule::default();
488 let t0 = Instant::now();
489 let stacks = |a: &super::super::StackDef, b: &super::super::StackDef| {
490 vec![
491 ("app".to_string(), a.clone()),
492 ("two".to_string(), b.clone()),
493 ]
494 };
495 let list = stacks(&def, &def2);
496 let it = || list.iter().map(|(q, d)| (q.as_str(), d));
497 assert!(sch.due(it(), t0).is_empty());
499 assert!(sch.due(it(), t0 + Duration::from_secs(29 * 60)).is_empty());
500 let d = sch.due(it(), t0 + Duration::from_secs(30 * 60));
501 assert_eq!(d, [("app".to_string(), "api".to_string())]);
502 assert!(sch.due(it(), t0 + Duration::from_secs(31 * 60)).is_empty());
504 let d = sch.due(it(), t0 + Duration::from_secs(60 * 60));
505 assert_eq!(
506 d,
507 [
508 ("app".to_string(), "api".to_string()),
509 ("two".to_string(), "api".to_string())
510 ]
511 );
512 sch.reset("app", "api", Duration::from_secs(30 * 60), t0);
514 assert_eq!(sch.due(it(), t0 + Duration::from_secs(30 * 60)).len(), 1);
515 let only = [("app".to_string(), def.clone())];
517 sch.due(only.iter().map(|(q, d)| (q.as_str(), d)), t0);
518 assert_eq!(sch.next.len(), 1);
519 }
520
521 #[test]
522 fn owned_names() {
523 assert_eq!(owned_name("app", "db").unwrap(), "app_db");
524 assert!(owned_name("app", "a/b").is_err());
525 }
526}