Skip to main content

isb_server/auth/
db.rs

1//! The identity database: SQLite in WAL mode, schema migrations recorded in
2//! `schema_version`.
3//!
4//! Times are unix seconds. Token columns hold SHA-256 hashes, never tokens.
5//! Roles are text, validated in Rust ([`super::Role`]), so a new role needs
6//! no migration.
7
8use std::path::Path;
9use std::time::Duration;
10
11use rusqlite::{Connection, OpenFlags};
12
13use super::AuthError;
14
15/// Each entry moves the schema from version `i` to `i + 1`. Append only:
16/// never edit a migration that has shipped.
17const MIGRATIONS: &[&str] = &[
18    // 1: users, identities, orgs, memberships, sessions, invitations, API
19    // tokens, password resets.
20    "
21    CREATE TABLE users (
22        id             INTEGER PRIMARY KEY,
23        email          TEXT NOT NULL UNIQUE COLLATE NOCASE,
24        name           TEXT NOT NULL DEFAULT '',
25        password_hash  TEXT,
26        platform_admin INTEGER NOT NULL DEFAULT 0,
27        created_at     INTEGER NOT NULL,
28        disabled       INTEGER NOT NULL DEFAULT 0
29    );
30    -- External sign-in methods (OAuth, OIDC): one row per provider account.
31    CREATE TABLE user_identities (
32        id             INTEGER PRIMARY KEY,
33        user_id        INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
34        provider       TEXT NOT NULL,
35        subject        TEXT NOT NULL,
36        email          TEXT,
37        email_verified INTEGER NOT NULL DEFAULT 0,
38        created_at     INTEGER NOT NULL,
39        last_used      INTEGER,
40        UNIQUE (provider, subject)
41    );
42    CREATE INDEX user_identities_user ON user_identities(user_id);
43    CREATE TABLE orgs (
44        name       TEXT PRIMARY KEY,
45        created_at INTEGER NOT NULL
46    );
47    CREATE TABLE memberships (
48        user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
49        org        TEXT NOT NULL REFERENCES orgs(name) ON DELETE CASCADE,
50        role       TEXT NOT NULL,
51        created_at INTEGER NOT NULL,
52        PRIMARY KEY (user_id, org)
53    );
54    CREATE INDEX memberships_org ON memberships(org);
55    CREATE TABLE sessions (
56        id         INTEGER PRIMARY KEY,
57        token_hash BLOB NOT NULL UNIQUE,
58        user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
59        created_at INTEGER NOT NULL,
60        last_seen  INTEGER NOT NULL,
61        expires_at INTEGER NOT NULL,
62        user_agent TEXT,
63        ip         TEXT
64    );
65    CREATE INDEX sessions_user ON sessions(user_id);
66    CREATE TABLE invitations (
67        id          INTEGER PRIMARY KEY,
68        token_hash  BLOB NOT NULL UNIQUE,
69        org         TEXT NOT NULL REFERENCES orgs(name) ON DELETE CASCADE,
70        email       TEXT NOT NULL COLLATE NOCASE,
71        role        TEXT NOT NULL,
72        invited_by  INTEGER REFERENCES users(id) ON DELETE SET NULL,
73        created_at  INTEGER NOT NULL,
74        expires_at  INTEGER NOT NULL,
75        accepted_at INTEGER,
76        accepted_by INTEGER REFERENCES users(id) ON DELETE SET NULL
77    );
78    CREATE INDEX invitations_org ON invitations(org);
79    CREATE TABLE api_tokens (
80        id         INTEGER PRIMARY KEY,
81        token_hash BLOB NOT NULL UNIQUE,
82        name       TEXT NOT NULL,
83        user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
84        org        TEXT REFERENCES orgs(name) ON DELETE CASCADE,
85        created_at INTEGER NOT NULL,
86        last_used  INTEGER,
87        expires_at INTEGER
88    );
89    CREATE INDEX api_tokens_user ON api_tokens(user_id);
90    CREATE INDEX api_tokens_org ON api_tokens(org);
91    CREATE TABLE password_resets (
92        id         INTEGER PRIMARY KEY,
93        token_hash BLOB NOT NULL UNIQUE,
94        user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
95        created_at INTEGER NOT NULL,
96        expires_at INTEGER NOT NULL,
97        used_at    INTEGER
98    );
99    ",
100    // 2: WebAuthn passkeys. `user_handle` is the random WebAuthn user id
101    // (the same for all of a user's passkeys); `public_key` is the COSE_Key.
102    "
103    CREATE TABLE passkeys (
104        id            INTEGER PRIMARY KEY,
105        credential_id BLOB NOT NULL UNIQUE,
106        user_id       INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
107        user_handle   BLOB NOT NULL,
108        public_key    BLOB NOT NULL,
109        alg           INTEGER NOT NULL,
110        sign_count    INTEGER NOT NULL DEFAULT 0,
111        transports    TEXT NOT NULL DEFAULT '[]',
112        aaguid        TEXT NOT NULL DEFAULT '',
113        name          TEXT NOT NULL DEFAULT '',
114        created_at    INTEGER NOT NULL,
115        last_used     INTEGER
116    );
117    CREATE INDEX passkeys_user ON passkeys(user_id);
118    ",
119    // 3: API token scopes, a JSON array of strings; NULL is the role's
120    // whole reach.
121    "
122    ALTER TABLE api_tokens ADD COLUMN scopes TEXT;
123    ",
124    // 4: superadmin tokens: the unix socket's reach over HTTP. Nobody's:
125    // minted on the host (`isb token create NAME --superadmin`), named
126    // uniquely so the audit log's `token:<name>` is unambiguous.
127    "
128    CREATE TABLE superadmin_tokens (
129        id         INTEGER PRIMARY KEY,
130        token_hash BLOB NOT NULL UNIQUE,
131        name       TEXT NOT NULL UNIQUE,
132        created_at INTEGER NOT NULL,
133        last_used  INTEGER,
134        expires_at INTEGER
135    );
136    ",
137    // 5: SSH public keys on accounts (`isb ssh-proxy` lets them into an
138    // instance). `public_key` is `algorithm base64`, nothing else.
139    "
140    CREATE TABLE ssh_keys (
141        id          INTEGER PRIMARY KEY,
142        user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
143        name        TEXT NOT NULL DEFAULT '',
144        algorithm   TEXT NOT NULL,
145        public_key  TEXT NOT NULL,
146        fingerprint TEXT NOT NULL,
147        created_at  INTEGER NOT NULL,
148        last_used   INTEGER,
149        UNIQUE (user_id, fingerprint)
150    );
151    ",
152    // 6: agent identities: tailnet logins and tags, Access emails and service
153    // token client ids, each mapped to a role in one org (never owner).
154    "
155    CREATE TABLE org_agent_identities (
156        id         INTEGER PRIMARY KEY,
157        org        TEXT NOT NULL REFERENCES orgs(name) ON DELETE CASCADE,
158        kind       TEXT NOT NULL,
159        subject    TEXT NOT NULL,
160        role       TEXT NOT NULL,
161        note       TEXT NOT NULL DEFAULT '',
162        created_at INTEGER NOT NULL,
163        created_by TEXT NOT NULL DEFAULT '',
164        UNIQUE (org, kind, subject)
165    );
166    CREATE INDEX org_agent_identities_subject ON org_agent_identities(kind, subject);
167    ",
168    // 7: superadmin identities kept as state (`isb superadmin add`), on top
169    // of `--superadmin-access` / `--superadmin-tailnet`: tailnet logins and
170    // tags, Access emails and service token client ids. Written only on the
171    // host, never over HTTP.
172    "
173    CREATE TABLE superadmin_identities (
174        id       INTEGER PRIMARY KEY,
175        kind     TEXT NOT NULL,
176        value    TEXT NOT NULL,
177        added_at INTEGER NOT NULL,
178        added_by TEXT NOT NULL DEFAULT '',
179        UNIQUE (kind, value)
180    );
181    ",
182];
183
184/// The schema version this build writes.
185pub const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64;
186
187/// Open (creating if needed) and migrate. The file is 0600 in a 0700
188/// directory: it holds password hashes.
189pub fn open(path: &Path) -> Result<Connection, AuthError> {
190    use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
191    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
192        if !dir.exists() {
193            std::fs::DirBuilder::new()
194                .recursive(true)
195                .mode(0o700)
196                .create(dir)
197                .map_err(|e| AuthError::io(format!("create {}", dir.display()), e))?;
198        }
199    }
200    // Create the file 0600 before SQLite does (it would use the umask).
201    std::fs::OpenOptions::new()
202        .create(true)
203        .append(true)
204        .mode(0o600)
205        .open(path)
206        .map_err(|e| AuthError::io(format!("open {}", path.display()), e))?;
207    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
208        .map_err(|e| AuthError::io(format!("chmod {}", path.display()), e))?;
209    let conn = Connection::open_with_flags(
210        path,
211        OpenFlags::SQLITE_OPEN_READ_WRITE
212            | OpenFlags::SQLITE_OPEN_CREATE
213            | OpenFlags::SQLITE_OPEN_NO_MUTEX,
214    )?;
215    configure(&conn)?;
216    migrate(&conn)?;
217    Ok(conn)
218}
219
220/// An in-memory database, for tests.
221pub fn open_in_memory() -> Result<Connection, AuthError> {
222    let conn = Connection::open_in_memory()?;
223    configure(&conn)?;
224    migrate(&conn)?;
225    Ok(conn)
226}
227
228fn configure(conn: &Connection) -> Result<(), AuthError> {
229    // The daemon and the CLI share the file; a writer waits up to 5s for the
230    // other instead of failing with SQLITE_BUSY.
231    conn.busy_timeout(Duration::from_secs(5))?;
232    let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?;
233    if mode != "wal" && mode != "memory" {
234        return Err(AuthError::Internal(format!(
235            "identity database: journal_mode is {mode}, wanted wal"
236        )));
237    }
238    conn.execute_batch(
239        "PRAGMA foreign_keys = ON;
240         PRAGMA synchronous = NORMAL;
241         PRAGMA secure_delete = ON;",
242    )?;
243    Ok(())
244}
245
246/// The version recorded in `schema_version`, 0 for a new database.
247pub fn version(conn: &Connection) -> Result<i64, AuthError> {
248    conn.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)")?;
249    Ok(conn
250        .query_row("SELECT MAX(version) FROM schema_version", [], |r| {
251            r.get::<_, Option<i64>>(0)
252        })?
253        .unwrap_or(0))
254}
255
256/// Apply every migration past the recorded version, each in its own
257/// transaction. A database from a newer isb is refused, not downgraded.
258pub fn migrate(conn: &Connection) -> Result<(), AuthError> {
259    let mut v = version(conn)?;
260    if v > SCHEMA_VERSION {
261        return Err(AuthError::Internal(format!(
262            "identity database is schema version {v}, newer than this isb understands \
263             ({SCHEMA_VERSION}); upgrade isb"
264        )));
265    }
266    while v < SCHEMA_VERSION {
267        let sql = MIGRATIONS[v as usize];
268        // IMMEDIATE takes the write lock up front, so two processes opening a
269        // new database at once cannot both run migration 1.
270        conn.execute_batch("BEGIN IMMEDIATE")?;
271        let r = (|| -> Result<(), AuthError> {
272            let now_v = version(conn)?;
273            if now_v != v {
274                // Someone else migrated while we waited for the lock.
275                return Ok(());
276            }
277            conn.execute_batch(sql)?;
278            conn.execute("INSERT INTO schema_version (version) VALUES (?1)", [v + 1])?;
279            Ok(())
280        })();
281        match r {
282            Ok(()) => conn.execute_batch("COMMIT")?,
283            Err(e) => {
284                let _ = conn.execute_batch("ROLLBACK");
285                return Err(AuthError::Internal(format!(
286                    "identity database migration {}: {e}",
287                    v + 1
288                )));
289            }
290        }
291        v = version(conn)?;
292    }
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn migrates_once_and_refuses_newer() {
302        let dir = tempfile::tempdir().unwrap();
303        let p = dir.path().join("sub/isb.db");
304        let c = open(&p).unwrap();
305        assert_eq!(version(&c).unwrap(), SCHEMA_VERSION);
306        let mode: String = c
307            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
308            .unwrap();
309        assert_eq!(mode, "wal");
310        drop(c);
311        {
312            use std::os::unix::fs::PermissionsExt;
313            let m = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
314            assert_eq!(m, 0o600);
315            let d = std::fs::metadata(p.parent().unwrap())
316                .unwrap()
317                .permissions()
318                .mode()
319                & 0o777;
320            assert_eq!(d, 0o700);
321        }
322        // Reopening applies nothing new.
323        let c = open(&p).unwrap();
324        let n: i64 = c
325            .query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0))
326            .unwrap();
327        assert_eq!(n, SCHEMA_VERSION);
328        // A database from the future is refused.
329        c.execute(
330            "INSERT INTO schema_version (version) VALUES (?1)",
331            [SCHEMA_VERSION + 1],
332        )
333        .unwrap();
334        drop(c);
335        let e = open(&p).unwrap_err().to_string();
336        assert!(e.contains("newer than this isb"), "{e}");
337    }
338
339    #[test]
340    fn upgrades_a_version_1_database() {
341        let dir = tempfile::tempdir().unwrap();
342        let p = dir.path().join("isb.db");
343        {
344            let c = Connection::open(&p).unwrap();
345            c.execute_batch(MIGRATIONS[0]).unwrap();
346            c.execute_batch(
347                "CREATE TABLE schema_version (version INTEGER NOT NULL);
348                 INSERT INTO schema_version VALUES (1);
349                 INSERT INTO users (email, created_at) VALUES ('a@x.io', 0);",
350            )
351            .unwrap();
352        }
353        let c = open(&p).unwrap();
354        assert_eq!(version(&c).unwrap(), SCHEMA_VERSION);
355        let n: i64 = c
356            .query_row("SELECT COUNT(*) FROM passkeys", [], |r| r.get(0))
357            .unwrap();
358        assert_eq!(n, 0);
359        let n: i64 = c
360            .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
361            .unwrap();
362        assert_eq!(n, 1);
363    }
364
365    #[test]
366    fn email_is_case_insensitive() {
367        let c = open_in_memory().unwrap();
368        c.execute(
369            "INSERT INTO users (email, created_at) VALUES ('A@x.io', 0)",
370            [],
371        )
372        .unwrap();
373        assert!(
374            c.execute(
375                "INSERT INTO users (email, created_at) VALUES ('a@X.IO', 0)",
376                [],
377            )
378            .is_err()
379        );
380    }
381}