Skip to main content

isb_core/stack/
secrets.rs

1//! A stack's secrets: references into its org's store, by name and version.
2//!
3//! A deployed stack never holds a value. Each top-level secret its services
4//! use becomes a [`SecretBinding`]: the name in the org's store (or a
5//! driver's reference), the driver, and the version deployed. Values are read
6//! from the store when they are delivered, and a new version is a new
7//! revision, so the services using it roll.
8//!
9//! - `external: true` names an existing secret in the org.
10//! - `file:` and `environment:` are read by the deploying client, and `age:`
11//!   is decrypted with the daemon's key; all three are stored as `local`
12//!   secrets named `<stack>_<key>`, as swarm does, and removed with the
13//!   stack.
14//! - `driver: X, name: REF` is read through driver X.
15
16use 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/// One top-level secret a stack uses, as deployed.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SecretBinding {
29    /// The name in the org's store, or the driver's reference.
30    pub name: String,
31    /// The driver holding it (`local`, ...).
32    pub driver: String,
33    /// The version deployed; a new one rolls the services using it.
34    pub version: u64,
35    /// The stack made it from a `file:`, `environment:` or `age:` source,
36    /// and removes it with the stack.
37    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38    pub owned: bool,
39}
40
41impl SecretBinding {
42    /// Held outside isb's own store (an external vault): polled for new
43    /// versions. A `local` secret changes only through isb, which rolls its
44    /// users there and then.
45    pub fn is_driver_backed(&self) -> bool {
46        self.driver != crate::secrets::local::DRIVER
47    }
48}
49
50/// `<stack>_<key>`: where a stack keeps a secret it was given a value for.
51pub 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
58/// The top-level secrets a file's services use (as files or variables).
59pub 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
75/// Bind every secret `file`'s services use, for deploying it as `stack` in
76/// `org`. `given` holds the values of `file:`/`environment:` secrets, read by
77/// the client. Values the stack owns are stored now, and only when changed,
78/// so an unchanged value keeps its version. With `dry_run` nothing is
79/// written; the versions are what a deploy would produce.
80pub 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
144/// The driver and version `put` would leave.
145fn 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    /// The value now in the store (its version may be newer than this
156    /// binding's; the controller rolls to it on its next check).
157    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
165/// The values of the given keys, read from the store through the stack's
166/// bindings.
167pub 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
185/// The values of a file's store-backed secrets (`external`, `age`,
186/// `driver`), for `isb up`, which runs no stack. `file:` and `environment:`
187/// secrets are skipped: the client reads those itself.
188pub 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
216/// A forced refresh: the (driver, version) of every binding to the name, and
217/// the stacks that rolled.
218pub type Refreshed = (Vec<(String, u64)>, Vec<String>);
219
220/// When each driver-backed binding is next due for a version check.
221#[derive(Debug, Default)]
222pub struct RefreshSchedule {
223    next: BTreeMap<(String, String), Instant>,
224}
225
226impl RefreshSchedule {
227    /// The `(stack, key)` pairs due at `now`, given each stack's bindings
228    /// and declared refresh intervals; each one returned is rescheduled. A
229    /// binding seen for the first time is due one interval after `now`: it
230    /// was just read at deploy.
231    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        // Forget stacks and keys that went away.
257        self.next.retain(|k, _| seen.contains(k));
258        out
259    }
260
261    /// Check sooner than scheduled (a forced refresh).
262    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    /// An external vault whose version the test moves.
275    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        // The external secret must exist.
348        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        // A dry run writes nothing.
356        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        // Values come from the store, never from the binding.
378        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        // The same value again keeps the version; a new one bumps it.
384        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        // A missing client value is an error naming the secret.
397        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        // Re-encrypting the same value (new ciphertext) is no new version.
419        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        // Ciphertext for another key fails, naming the secret.
425        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        // isb up's resolution reads the same sources.
431        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        // A driver binding without a declared refresh uses the default.
481        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        // Nothing is due right after deploy; local bindings never are.
498        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        // Rescheduled: not due again until another 30m.
503        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        // A forced check moves the next one.
513        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        // A stack that went away is forgotten.
516        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}