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];
169
170pub const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64;
172
173pub 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 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
206pub 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 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
232pub 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
242pub 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 conn.execute_batch("BEGIN IMMEDIATE")?;
257 let r = (|| -> Result<(), AuthError> {
258 let now_v = version(conn)?;
259 if now_v != v {
260 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 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 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}