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];
169
170/// The schema version this build writes.
171pub const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64;
172
173/// Open (creating if needed) and migrate. The file is 0600 in a 0700
174/// directory: it holds password hashes.
175pub fn open(path: &Path) -> Result<Connection, AuthError> {
176    use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
177    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
178        if !dir.exists() {
179            std::fs::DirBuilder::new()
180                .recursive(true)
181                .mode(0o700)
182                .create(dir)
183                .map_err(|e| AuthError::io(format!("create {}", dir.display()), e))?;
184        }
185    }
186    // Create the file 0600 before SQLite does (it would use the umask).
187    std::fs::OpenOptions::new()
188        .create(true)
189        .append(true)
190        .mode(0o600)
191        .open(path)
192        .map_err(|e| AuthError::io(format!("open {}", path.display()), e))?;
193    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
194        .map_err(|e| AuthError::io(format!("chmod {}", path.display()), e))?;
195    let conn = Connection::open_with_flags(
196        path,
197        OpenFlags::SQLITE_OPEN_READ_WRITE
198            | OpenFlags::SQLITE_OPEN_CREATE
199            | OpenFlags::SQLITE_OPEN_NO_MUTEX,
200    )?;
201    configure(&conn)?;
202    migrate(&conn)?;
203    Ok(conn)
204}
205
206/// An in-memory database, for tests.
207pub fn open_in_memory() -> Result<Connection, AuthError> {
208    let conn = Connection::open_in_memory()?;
209    configure(&conn)?;
210    migrate(&conn)?;
211    Ok(conn)
212}
213
214fn configure(conn: &Connection) -> Result<(), AuthError> {
215    // The daemon and the CLI share the file; a writer waits up to 5s for the
216    // other instead of failing with SQLITE_BUSY.
217    conn.busy_timeout(Duration::from_secs(5))?;
218    let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?;
219    if mode != "wal" && mode != "memory" {
220        return Err(AuthError::Internal(format!(
221            "identity database: journal_mode is {mode}, wanted wal"
222        )));
223    }
224    conn.execute_batch(
225        "PRAGMA foreign_keys = ON;
226         PRAGMA synchronous = NORMAL;
227         PRAGMA secure_delete = ON;",
228    )?;
229    Ok(())
230}
231
232/// The version recorded in `schema_version`, 0 for a new database.
233pub fn version(conn: &Connection) -> Result<i64, AuthError> {
234    conn.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)")?;
235    Ok(conn
236        .query_row("SELECT MAX(version) FROM schema_version", [], |r| {
237            r.get::<_, Option<i64>>(0)
238        })?
239        .unwrap_or(0))
240}
241
242/// Apply every migration past the recorded version, each in its own
243/// transaction. A database from a newer isb is refused, not downgraded.
244pub fn migrate(conn: &Connection) -> Result<(), AuthError> {
245    let mut v = version(conn)?;
246    if v > SCHEMA_VERSION {
247        return Err(AuthError::Internal(format!(
248            "identity database is schema version {v}, newer than this isb understands \
249             ({SCHEMA_VERSION}); upgrade isb"
250        )));
251    }
252    while v < SCHEMA_VERSION {
253        let sql = MIGRATIONS[v as usize];
254        // IMMEDIATE takes the write lock up front, so two processes opening a
255        // new database at once cannot both run migration 1.
256        conn.execute_batch("BEGIN IMMEDIATE")?;
257        let r = (|| -> Result<(), AuthError> {
258            let now_v = version(conn)?;
259            if now_v != v {
260                // Someone else migrated while we waited for the lock.
261                return Ok(());
262            }
263            conn.execute_batch(sql)?;
264            conn.execute("INSERT INTO schema_version (version) VALUES (?1)", [v + 1])?;
265            Ok(())
266        })();
267        match r {
268            Ok(()) => conn.execute_batch("COMMIT")?,
269            Err(e) => {
270                let _ = conn.execute_batch("ROLLBACK");
271                return Err(AuthError::Internal(format!(
272                    "identity database migration {}: {e}",
273                    v + 1
274                )));
275            }
276        }
277        v = version(conn)?;
278    }
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn migrates_once_and_refuses_newer() {
288        let dir = tempfile::tempdir().unwrap();
289        let p = dir.path().join("sub/isb.db");
290        let c = open(&p).unwrap();
291        assert_eq!(version(&c).unwrap(), SCHEMA_VERSION);
292        let mode: String = c
293            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
294            .unwrap();
295        assert_eq!(mode, "wal");
296        drop(c);
297        {
298            use std::os::unix::fs::PermissionsExt;
299            let m = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
300            assert_eq!(m, 0o600);
301            let d = std::fs::metadata(p.parent().unwrap())
302                .unwrap()
303                .permissions()
304                .mode()
305                & 0o777;
306            assert_eq!(d, 0o700);
307        }
308        // Reopening applies nothing new.
309        let c = open(&p).unwrap();
310        let n: i64 = c
311            .query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0))
312            .unwrap();
313        assert_eq!(n, SCHEMA_VERSION);
314        // A database from the future is refused.
315        c.execute(
316            "INSERT INTO schema_version (version) VALUES (?1)",
317            [SCHEMA_VERSION + 1],
318        )
319        .unwrap();
320        drop(c);
321        let e = open(&p).unwrap_err().to_string();
322        assert!(e.contains("newer than this isb"), "{e}");
323    }
324
325    #[test]
326    fn upgrades_a_version_1_database() {
327        let dir = tempfile::tempdir().unwrap();
328        let p = dir.path().join("isb.db");
329        {
330            let c = Connection::open(&p).unwrap();
331            c.execute_batch(MIGRATIONS[0]).unwrap();
332            c.execute_batch(
333                "CREATE TABLE schema_version (version INTEGER NOT NULL);
334                 INSERT INTO schema_version VALUES (1);
335                 INSERT INTO users (email, created_at) VALUES ('a@x.io', 0);",
336            )
337            .unwrap();
338        }
339        let c = open(&p).unwrap();
340        assert_eq!(version(&c).unwrap(), SCHEMA_VERSION);
341        let n: i64 = c
342            .query_row("SELECT COUNT(*) FROM passkeys", [], |r| r.get(0))
343            .unwrap();
344        assert_eq!(n, 0);
345        let n: i64 = c
346            .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
347            .unwrap();
348        assert_eq!(n, 1);
349    }
350
351    #[test]
352    fn email_is_case_insensitive() {
353        let c = open_in_memory().unwrap();
354        c.execute(
355            "INSERT INTO users (email, created_at) VALUES ('A@x.io', 0)",
356            [],
357        )
358        .unwrap();
359        assert!(
360            c.execute(
361                "INSERT INTO users (email, created_at) VALUES ('a@X.IO', 0)",
362                [],
363            )
364            .is_err()
365        );
366    }
367}