Skip to main content

isb_apps/app/
db_secrets.rs

1//! A database app's credentials as org secrets: its passwords, generated
2//! once, and the URL secrets that carry the password, kept in step with it.
3
4use super::Apps;
5use crate::app::{AppSpec, Source, git};
6use crate::error::{Error, Result};
7use crate::org::OrgId;
8
9impl Apps {
10    /// A database's passwords, generated unless they exist (a database
11    /// re-created over its kept volume needs the passwords that volume was
12    /// initialized with), and its connection URL.
13    pub(super) fn database_credentials(
14        &self,
15        org: &OrgId,
16        spec: &AppSpec,
17        db: &crate::app::DatabaseSource,
18    ) -> Result<()> {
19        use crate::app::database as d;
20        let mut names = vec![d::password_secret(&spec.name)];
21        if db.engine.has_root_password() {
22            names.push(d::root_password_secret(&spec.name));
23        }
24        for n in &names {
25            match self.inner.secrets.inspect(org, n) {
26                Ok(_) => {}
27                Err(e) if e.is_not_found() => {
28                    self.inner
29                        .secrets
30                        .set(org, n, git::random_hex(16).as_bytes())?;
31                }
32                Err(e) => return Err(e),
33            }
34        }
35        self.write_urls(org, spec, db)?;
36        Ok(())
37    }
38
39    /// Store the URL secret and the database's `urls` again from its
40    /// current password; returns the names whose value moved.
41    fn write_urls(
42        &self,
43        org: &OrgId,
44        spec: &AppSpec,
45        db: &crate::app::DatabaseSource,
46    ) -> Result<Vec<String>> {
47        use crate::app::database as d;
48        let (pw, _) = self
49            .inner
50            .secrets
51            .get(org, &d::password_secret(&spec.name))?;
52        let pw = String::from_utf8(pw).map_err(|_| Error::invalid("the password is not text"))?;
53        let url = d::internal_url(spec, db, pw.trim());
54        let wanted = std::iter::once((d::url_secret(&spec.name), url.clone())).chain(
55            db.urls
56                .iter()
57                .map(|(n, q)| (n.clone(), d::with_query(&url, q))),
58        );
59        let mut moved = Vec::new();
60        for (name, value) in wanted {
61            let before = self.inner.secrets.version(org, &name).ok();
62            let m = self.inner.secrets.put(org, &name, value.as_bytes())?;
63            if Some(m.version) != before {
64                moved.push(m.name);
65            }
66        }
67        Ok(moved)
68    }
69
70    /// The secret `name` got a new value: when it is a database app's
71    /// password (`db.<app>.password`), store the URL secret and the
72    /// database's `urls` again with it. Returns the secrets whose value
73    /// moved.
74    pub fn database_password_changed(&self, org: &OrgId, name: &str) -> Result<Vec<String>> {
75        let Some(app) = name
76            .strip_prefix("db.")
77            .and_then(|r| r.strip_suffix(".password"))
78        else {
79            return Ok(vec![]);
80        };
81        let a = match self.get(org, app) {
82            Ok(a) => a,
83            Err(e) if e.is_not_found() => return Ok(vec![]),
84            Err(e) => return Err(e),
85        };
86        let Source::Database(db) = &a.spec.source else {
87            return Ok(vec![]);
88        };
89        self.write_urls(org, &a.spec, db)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use std::sync::Arc;
96    use std::time::Duration;
97
98    use super::*;
99    use crate::secrets::{Keyring, LocalDriver, Secrets};
100
101    #[test]
102    fn url_secrets_follow_the_password() {
103        let dir = tempfile::tempdir().unwrap();
104        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
105        let secrets = Arc::new(Secrets::new(LocalDriver::new(dir.path(), Arc::new(k))));
106        let client = crate::client::Client::with_socket("/nonexistent/isb-test/incus.sock");
107        let store = crate::stack::Store::open(dir.path()).unwrap();
108        let ctl = crate::stack::Controller::start(
109            client.clone(),
110            store,
111            Duration::from_secs(60),
112            secrets.clone(),
113        )
114        .unwrap();
115        let ap = Apps::new(dir.path(), client, ctl, secrets.clone());
116        let org = OrgId::default_org();
117        let spec: AppSpec = serde_json::from_value(serde_json::json!({
118            "name": "main-db", "project": "shop",
119            "source": {"database": {"engine": "postgres", "urls": {
120                "dsn.main-db.web": "sslmode=disable", "dsn.main-db.plain": "",
121            }}},
122        }))
123        .unwrap();
124        ap.project_create(&org, "shop", "", &[]).unwrap();
125        ap.create(&org, spec).unwrap();
126        let Source::Database(db) = &ap.get(&org, "main-db").unwrap().spec.source else {
127            panic!("a database")
128        };
129        let value = |n: &str| String::from_utf8(secrets.get(&org, n).unwrap().0).unwrap();
130        ap.database_credentials(&org, &ap.get(&org, "main-db").unwrap().spec, db)
131            .unwrap();
132        let url = value("db.main-db.url");
133        assert_eq!(value("dsn.main-db.web"), format!("{url}?sslmode=disable"));
134        assert_eq!(value("dsn.main-db.plain"), url);
135
136        secrets
137            .set(&org, "db.main-db.password", b"n3w-pass")
138            .unwrap();
139        let mut moved = ap
140            .database_password_changed(&org, "db.main-db.password")
141            .unwrap();
142        moved.sort();
143        assert_eq!(
144            moved,
145            ["db.main-db.url", "dsn.main-db.plain", "dsn.main-db.web"]
146        );
147        assert!(value("dsn.main-db.web").contains(":n3w-pass@"));
148        assert!(value("dsn.main-db.web").ends_with("?sslmode=disable"));
149        assert!(
150            ap.database_password_changed(&org, "db.main-db.password")
151                .unwrap()
152                .is_empty(),
153            "nothing moves twice"
154        );
155        assert!(
156            ap.database_password_changed(&org, "dsn.main-db.web")
157                .unwrap()
158                .is_empty()
159        );
160    }
161}