1use std::path::Path;
9use std::time::Duration;
10
11use rusqlite::{Connection, OpenFlags};
12
13use super::AuthError;
14
15const MIGRATIONS: &[&str] = &[
18 "
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 "
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 "
122 ALTER TABLE api_tokens ADD COLUMN scopes TEXT;
123 ",
124 "
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 "
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 "
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 "
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
184pub const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64;
186
187pub 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 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
220pub 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 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
246pub 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
256pub 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 conn.execute_batch("BEGIN IMMEDIATE")?;
271 let r = (|| -> Result<(), AuthError> {
272 let now_v = version(conn)?;
273 if now_v != v {
274 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 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 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}