1use crate::iso_now;
20use rand::RngCore;
21use rusqlite::{Connection, OptionalExtension};
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24use std::collections::{HashMap, HashSet};
25
26type Scope = (String, String);
31
32type PermissionsBySubject = HashMap<String, (Vec<Scope>, Vec<Scope>)>;
37
38type UserRow = (
45 String,
46 String,
47 Option<String>,
48 i32,
49 String,
50 Option<String>,
51 Option<String>,
52 i32,
53 i32,
54);
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct UserClass {
61 pub id: String,
62 pub name: String,
63 pub description: String,
64 pub cap_manage_users: bool,
66 pub cap_manage_classes: bool,
68 pub cap_delete_projects: bool,
70 pub strict_write: bool,
71 pub created_at: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ClassPermission {
77 pub class_id: String,
78 pub scope_type: String,
79 pub scope_value: String,
80 pub permission: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct UserRecord {
86 pub id: String,
87 pub username: String,
88 pub has_password: bool,
90 pub is_owner: bool,
91 pub created_at: String,
92 pub last_seen_at: Option<String>,
93 pub class_id: Option<String>,
94 pub strict_write: bool,
95 pub totp_enabled: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TokenRecord {
103 pub id: String,
104 pub user_id: String,
105 pub description: Option<String>,
106 pub created_at: String,
107 pub expires_at: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct PermissionRecord {
113 pub user_id: String,
114 pub scope_type: String,
116 pub scope_value: String,
118 pub permission: String,
120}
121
122pub fn init_users_schema(conn: &Connection) -> Result<(), String> {
126 conn.execute_batch(
127 "CREATE TABLE IF NOT EXISTS users (
128 id TEXT PRIMARY KEY,
129 username TEXT NOT NULL UNIQUE,
130 password_hash TEXT,
131 is_owner INTEGER NOT NULL DEFAULT 0,
132 created_at TEXT NOT NULL,
133 last_seen_at TEXT
134 );
135 CREATE TABLE IF NOT EXISTS user_tokens (
136 id TEXT PRIMARY KEY,
137 token_hash TEXT NOT NULL UNIQUE,
138 user_id TEXT NOT NULL,
139 description TEXT,
140 created_at TEXT NOT NULL,
141 expires_at TEXT
142 );
143 CREATE TABLE IF NOT EXISTS user_permissions (
144 user_id TEXT NOT NULL,
145 scope_type TEXT NOT NULL,
146 scope_value TEXT NOT NULL,
147 permission TEXT NOT NULL,
148 PRIMARY KEY (user_id, scope_type, scope_value)
149 );
150 CREATE TABLE IF NOT EXISTS user_classes (
151 id TEXT PRIMARY KEY,
152 name TEXT NOT NULL UNIQUE,
153 description TEXT NOT NULL DEFAULT '',
154 cap_manage_users INTEGER NOT NULL DEFAULT 0,
155 cap_manage_classes INTEGER NOT NULL DEFAULT 0,
156 cap_delete_projects INTEGER NOT NULL DEFAULT 0,
157 created_at TEXT NOT NULL
158 );
159 CREATE TABLE IF NOT EXISTS user_class_permissions (
160 class_id TEXT NOT NULL,
161 scope_type TEXT NOT NULL,
162 scope_value TEXT NOT NULL,
163 permission TEXT NOT NULL,
164 PRIMARY KEY (class_id, scope_type, scope_value)
165 );
166 CREATE TABLE IF NOT EXISTS permission_expressions (
167 subject_kind TEXT NOT NULL, -- 'user' | 'class'
168 subject_id TEXT NOT NULL,
169 permission TEXT NOT NULL, -- 'read' | 'write'
170 expression TEXT NOT NULL,
171 PRIMARY KEY (subject_kind, subject_id, permission)
172 );",
173 )
174 .map_err(|e| e.to_string())?;
175
176 conn.execute("ALTER TABLE users ADD COLUMN class_id TEXT", [])
178 .ok();
179 conn.execute(
183 "ALTER TABLE users ADD COLUMN strict_write INTEGER NOT NULL DEFAULT 0",
184 [],
185 )
186 .ok();
187 conn.execute(
188 "ALTER TABLE user_classes ADD COLUMN strict_write INTEGER NOT NULL DEFAULT 0",
189 [],
190 )
191 .ok();
192 conn.execute("ALTER TABLE users ADD COLUMN totp_secret TEXT", [])
200 .ok();
201 conn.execute(
202 "ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0",
203 [],
204 )
205 .ok();
206 conn.execute("ALTER TABLE users ADD COLUMN totp_last_step INTEGER", [])
207 .ok();
208
209 let class_count: i32 = conn
211 .query_row("SELECT COUNT(*) FROM user_classes", [], |r| r.get(0))
212 .unwrap_or(0);
213 if class_count == 0 {
214 seed_default_classes(conn)?;
215 }
216
217 migrate_rows_to_expressions(conn)?;
222 Ok(())
223}
224
225fn migrate_rows_to_expressions(conn: &Connection) -> Result<(), String> {
235 let done: Option<String> = conn
236 .query_row(
237 "SELECT value FROM vault_meta WHERE key = 'perm_expr_migrated'",
238 [],
239 |r| r.get(0),
240 )
241 .optional()
242 .map_err(|e| e.to_string())?;
243 if done.is_some() {
244 return Ok(());
245 }
246
247 for (kind, table, id_col) in [
248 ("user", "user_permissions", "user_id"),
249 ("class", "user_class_permissions", "class_id"),
250 ] {
251 let sql = format!("SELECT {id_col}, scope_type, scope_value, permission FROM {table}");
252 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
253 let rows: Vec<(String, String, String, String)> = stmt
254 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
255 .map_err(|e| e.to_string())?
256 .filter_map(Result::ok)
257 .collect();
258
259 let mut by_subject: PermissionsBySubject = HashMap::new();
261 for (sid, scope_type, scope_value, permission) in rows {
262 let e = by_subject.entry(sid).or_default();
263 e.0.push((scope_type.clone(), scope_value.clone()));
264 if permission == "write" {
265 e.1.push((scope_type, scope_value));
266 }
267 }
268
269 for (sid, (read_scopes, write_scopes)) in by_subject {
270 for (perm, scopes) in [("read", read_scopes), ("write", write_scopes)] {
271 let refs: Vec<(&str, &str)> = scopes
272 .iter()
273 .map(|(a, b)| (a.as_str(), b.as_str()))
274 .collect();
275 if let Some(expr) = crate::permex::compile_scopes(refs) {
276 conn.execute(
277 "INSERT OR REPLACE INTO permission_expressions \
278 (subject_kind, subject_id, permission, expression) VALUES (?1, ?2, ?3, ?4)",
279 rusqlite::params![kind, sid, perm, expr.to_string()],
280 ).map_err(|e| e.to_string())?;
281 }
282 }
283 }
284 }
285
286 conn.execute(
287 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('perm_expr_migrated', ?1)",
288 rusqlite::params![iso_now()],
289 )
290 .map_err(|e| e.to_string())?;
291 Ok(())
292}
293
294pub fn get_permission_expr(
296 conn: &Connection,
297 subject_kind: &str,
298 subject_id: &str,
299 permission: &str,
300) -> Result<Option<String>, String> {
301 conn.query_row(
302 "SELECT expression FROM permission_expressions \
303 WHERE subject_kind = ?1 AND subject_id = ?2 AND permission = ?3",
304 rusqlite::params![subject_kind, subject_id, permission],
305 |r| r.get(0),
306 )
307 .optional()
308 .map_err(|e| e.to_string())
309}
310
311pub fn set_permission_expr(
316 conn: &Connection,
317 subject_kind: &str,
318 subject_id: &str,
319 permission: &str,
320 expression: &str,
321) -> Result<(), String> {
322 if !matches!(subject_kind, "user" | "class") {
323 return Err(format!("unknown subject kind '{subject_kind}'"));
324 }
325 if !matches!(permission, "read" | "write") {
326 return Err(format!("unknown permission '{permission}'"));
327 }
328 if expression.trim().is_empty() {
329 conn.execute(
330 "DELETE FROM permission_expressions \
331 WHERE subject_kind = ?1 AND subject_id = ?2 AND permission = ?3",
332 rusqlite::params![subject_kind, subject_id, permission],
333 )
334 .map_err(|e| e.to_string())?;
335 return Ok(());
336 }
337 let parsed = crate::permex::parse(expression)?;
338 conn.execute(
339 "INSERT OR REPLACE INTO permission_expressions \
340 (subject_kind, subject_id, permission, expression) VALUES (?1, ?2, ?3, ?4)",
341 rusqlite::params![subject_kind, subject_id, permission, parsed.to_string()],
342 )
343 .map_err(|e| e.to_string())?;
344 Ok(())
345}
346
347pub fn strict_write_for(conn: &Connection, user_id: &str) -> Result<bool, String> {
354 let user: i64 = conn
355 .query_row(
356 "SELECT COALESCE(strict_write, 0) FROM users WHERE id = ?1",
357 rusqlite::params![user_id],
358 |r| r.get(0),
359 )
360 .optional()
361 .map_err(|e| e.to_string())?
362 .unwrap_or(0);
363 if user != 0 {
364 return Ok(true);
365 }
366 let class: i64 = conn
367 .query_row(
368 "SELECT COALESCE(c.strict_write, 0) FROM users u \
369 JOIN user_classes c ON c.id = u.class_id WHERE u.id = ?1",
370 rusqlite::params![user_id],
371 |r| r.get(0),
372 )
373 .optional()
374 .map_err(|e| e.to_string())?
375 .unwrap_or(0);
376 Ok(class != 0)
377}
378
379pub fn set_strict_write(
381 conn: &Connection,
382 subject_kind: &str,
383 subject_id: &str,
384 strict: bool,
385) -> Result<(), String> {
386 let table = match subject_kind {
387 "user" => "users",
388 "class" => "user_classes",
389 other => return Err(format!("unknown subject kind '{other}'")),
390 };
391 let n = conn
392 .execute(
393 &format!("UPDATE {table} SET strict_write = ?1 WHERE id = ?2"),
394 rusqlite::params![i64::from(strict), subject_id],
395 )
396 .map_err(|e| e.to_string())?;
397 if n == 0 {
398 return Err(format!("No such {subject_kind}: {subject_id}"));
399 }
400 Ok(())
401}
402
403pub fn effective_permission_expr(
414 conn: &Connection,
415 user_id: &str,
416 permission: &str,
417) -> Result<Option<crate::permex::Expr>, String> {
418 let class_id: Option<String> = conn
419 .query_row(
420 "SELECT class_id FROM users WHERE id = ?1",
421 rusqlite::params![user_id],
422 |r| r.get(0),
423 )
424 .optional()
425 .map_err(|e| e.to_string())?
426 .flatten();
427
428 let load = |kind: &str, id: &str, perm: &str| -> Result<Option<crate::permex::Expr>, String> {
431 Ok(get_permission_expr(conn, kind, id, perm)?
432 .and_then(|src| crate::permex::parse(&src).ok()))
433 };
434
435 let raw = |perm: &str| -> Result<Option<crate::permex::Expr>, String> {
436 let individual = load("user", user_id, perm)?;
437 let class = match &class_id {
438 Some(cid) => load("class", cid, perm)?,
439 None => None,
440 };
441 Ok(crate::permex::combine(class, individual))
442 };
443
444 match permission {
445 "write" => Ok(raw("write")?.map(|e| {
450 if strict_write_for(conn, user_id).unwrap_or(false) {
451 crate::permex::require_all(e)
452 } else {
453 e
454 }
455 })),
456 "read" => Ok(crate::permex::any_of(raw("read")?, raw("write")?)),
459 other => Err(format!("unknown permission '{other}'")),
460 }
461}
462
463fn seed_default_classes(conn: &Connection) -> Result<(), String> {
465 let now = iso_now();
466
467 struct ClassSeed<'a> {
468 id: &'a str,
469 name: &'a str,
470 description: &'a str,
471 cap_manage_users: i32,
472 cap_manage_classes: i32,
473 cap_delete_projects: i32,
474 perms: &'a [(&'a str, &'a str, &'a str)], }
476
477 let seeds = [
478 ClassSeed {
479 id: "cls-admin",
480 name: "Admin",
481 description: "Full vault access. Can manage users and classes.",
482 cap_manage_users: 1,
483 cap_manage_classes: 1,
484 cap_delete_projects: 1,
485 perms: &[("vault", "*", "write")],
486 },
487 ClassSeed {
488 id: "cls-moderator",
489 name: "Moderator",
490 description: "Full vault access. Can manage users but not classes or project deletion.",
491 cap_manage_users: 1,
492 cap_manage_classes: 0,
493 cap_delete_projects: 0,
494 perms: &[("vault", "*", "write")],
495 },
496 ClassSeed {
497 id: "cls-viewer",
498 name: "Viewer",
499 description: "Read-only access to the entire vault.",
500 cap_manage_users: 0,
501 cap_manage_classes: 0,
502 cap_delete_projects: 0,
503 perms: &[("vault", "*", "read")],
504 },
505 ];
506
507 for s in &seeds {
508 conn.execute(
509 "INSERT OR IGNORE INTO user_classes (id, name, description, cap_manage_users, cap_manage_classes, cap_delete_projects, created_at) \
510 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
511 rusqlite::params![s.id, s.name, s.description, s.cap_manage_users, s.cap_manage_classes, s.cap_delete_projects, now],
512 ).map_err(|e| e.to_string())?;
513 for (scope_type, scope_value, permission) in s.perms {
514 conn.execute(
515 "INSERT OR IGNORE INTO user_class_permissions (class_id, scope_type, scope_value, permission) VALUES (?1, ?2, ?3, ?4)",
516 rusqlite::params![s.id, scope_type, scope_value, permission],
517 ).map_err(|e| e.to_string())?;
518 }
519 }
520 Ok(())
521}
522
523fn new_uuid() -> String {
530 crate::new_uuid()
531}
532
533fn hash_password(password: &str) -> String {
539 use argon2::password_hash::{rand_core::OsRng, SaltString};
540 use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
541
542 let salt = SaltString::generate(&mut OsRng);
543 let params = Params::new(32_768, 2, 1, None).expect("valid argon2 params");
544 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
545 argon2
546 .hash_password(password.as_bytes(), &salt)
547 .expect("argon2 hash")
548 .to_string()
549}
550
551fn verify_password_hash(password: &str, stored: &str) -> bool {
560 if stored.starts_with("$argon2") {
561 use argon2::password_hash::PasswordHash;
562 use argon2::{Argon2, PasswordVerifier};
563 let Ok(parsed) = PasswordHash::new(stored) else {
564 return false;
565 };
566 Argon2::default()
567 .verify_password(password.as_bytes(), &parsed)
568 .is_ok()
569 } else {
570 let mut parts = stored.splitn(2, ':');
572 let (Some(salt_hex), Some(hash_hex)) = (parts.next(), parts.next()) else {
573 return false;
574 };
575 let Ok(salt) = hex::decode(salt_hex) else {
576 return false;
577 };
578 let Ok(expected) = hex::decode(hash_hex) else {
579 return false;
580 };
581 let mut h = Sha256::new();
582 h.update(&salt);
583 h.update(password.as_bytes());
584 let computed = h.finalize();
585 computed.len() == expected.len()
587 && computed
588 .iter()
589 .zip(expected.iter())
590 .fold(0u8, |acc, (a, b)| acc | (a ^ b))
591 == 0
592 }
593}
594
595fn spend_verify_time(password: &str) {
602 static DUMMY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
603 let dummy = DUMMY.get_or_init(|| hash_password("unv-dummy-never-a-real-password"));
604 let _ = verify_password_hash(password, dummy);
605 #[cfg(test)]
606 DUMMY_VERIFIES.with(|c| c.set(c.get() + 1));
607}
608
609#[cfg(test)]
612thread_local! {
613 static DUMMY_VERIFIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
614}
615
616fn sha256_hex(input: &str) -> String {
617 let mut h = Sha256::new();
618 h.update(input.as_bytes());
619 hex::encode(h.finalize())
620}
621
622fn touch_last_seen(conn: &Connection, user_id: &str) -> Result<(), String> {
623 conn.execute(
624 "UPDATE users SET last_seen_at = ?1 WHERE id = ?2",
625 rusqlite::params![iso_now(), user_id],
626 )
627 .map(|_| ())
628 .map_err(|e| e.to_string())
629}
630
631pub fn create_user(
635 conn: &Connection,
636 username: &str,
637 password: Option<&str>,
638 is_owner: bool,
639) -> Result<UserRecord, String> {
640 let id = new_uuid();
641 let now = iso_now();
642 let password_hash = password.map(hash_password);
643 conn.execute(
644 "INSERT INTO users (id, username, password_hash, is_owner, created_at) \
645 VALUES (?1, ?2, ?3, ?4, ?5)",
646 rusqlite::params![id, username, password_hash, is_owner as i32, now],
647 )
648 .map_err(|e| e.to_string())?;
649 Ok(UserRecord {
650 id,
651 username: username.to_string(),
652 has_password: password.is_some(),
653 is_owner,
654 created_at: now,
655 last_seen_at: None,
656 class_id: None,
657 strict_write: false,
658 totp_enabled: false,
659 })
660}
661
662pub fn rename_user(conn: &Connection, user_id: &str, new_username: &str) -> Result<(), String> {
664 conn.execute(
665 "UPDATE users SET username = ?1 WHERE id = ?2",
666 rusqlite::params![new_username, user_id],
667 )
668 .map(|_| ())
669 .map_err(|e| e.to_string())
670}
671
672pub fn set_user_password(
674 conn: &Connection,
675 user_id: &str,
676 password: Option<&str>,
677) -> Result<(), String> {
678 conn.execute(
679 "UPDATE users SET password_hash = ?1 WHERE id = ?2",
680 rusqlite::params![password.map(hash_password), user_id],
681 )
682 .map(|_| ())
683 .map_err(|e| e.to_string())
684}
685
686pub fn verify_user_password(
688 conn: &Connection,
689 username: &str,
690 password: &str,
691) -> Result<Option<UserRecord>, String> {
692 let row: Option<UserRow> = conn
693 .query_row(
694 "SELECT id, username, password_hash, is_owner, created_at, last_seen_at, class_id, strict_write, totp_enabled \
695 FROM users WHERE username = ?1",
696 rusqlite::params![username],
697 |r| {
698 Ok((
699 r.get(0)?,
700 r.get(1)?,
701 r.get(2)?,
702 r.get(3)?,
703 r.get(4)?,
704 r.get(5)?,
705 r.get(6)?,
706 r.get(7)?,
707 r.get(8)?,
708 ))
709 },
710 )
711 .optional()
712 .map_err(|e| e.to_string())?;
713
714 let Some((
715 id,
716 uname,
717 hash_opt,
718 is_owner_i,
719 created_at,
720 last_seen,
721 class_id,
722 strict_write,
723 totp_on,
724 )) = row
725 else {
726 spend_verify_time(password);
727 return Ok(None);
728 };
729 let Some(stored) = hash_opt else {
738 spend_verify_time(password);
739 return Ok(None);
740 };
741 if !verify_password_hash(password, &stored) {
742 return Ok(None);
743 }
744 if !stored.starts_with("$argon2") {
746 let new_hash = hash_password(password);
747 let _ = conn.execute(
748 "UPDATE users SET password_hash = ?1 WHERE id = ?2",
749 rusqlite::params![new_hash, id],
750 );
751 }
752 touch_last_seen(conn, &id)?;
753 Ok(Some(UserRecord {
754 id,
755 username: uname,
756 has_password: true,
757 is_owner: is_owner_i != 0,
758 created_at,
759 last_seen_at: last_seen,
760 class_id,
761 strict_write: strict_write != 0,
762 totp_enabled: totp_on != 0,
763 }))
764}
765
766pub fn verify_user_token(conn: &Connection, token: &str) -> Result<Option<UserRecord>, String> {
768 let token_hash = sha256_hex(token);
769 let now = iso_now();
770 let row: Option<UserRow> = conn
771 .query_row(
772 "SELECT u.id, u.username, u.password_hash, u.is_owner, u.created_at, u.last_seen_at, \
773 u.class_id, u.strict_write, u.totp_enabled \
774 FROM user_tokens t JOIN users u ON u.id = t.user_id \
775 WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > ?2)",
776 rusqlite::params![token_hash, now],
777 |r| {
778 Ok((
779 r.get(0)?,
780 r.get(1)?,
781 r.get(2)?,
782 r.get(3)?,
783 r.get(4)?,
784 r.get(5)?,
785 r.get(6)?,
786 r.get(7)?,
787 r.get(8)?,
788 ))
789 },
790 )
791 .optional()
792 .map_err(|e| e.to_string())?;
793
794 let Some((
795 id,
796 uname,
797 hash_opt,
798 is_owner_i,
799 created_at,
800 last_seen,
801 class_id,
802 strict_write,
803 totp_on,
804 )) = row
805 else {
806 return Ok(None);
807 };
808 touch_last_seen(conn, &id)?;
809 Ok(Some(UserRecord {
810 id,
811 username: uname,
812 has_password: hash_opt.is_some(),
813 is_owner: is_owner_i != 0,
814 created_at,
815 last_seen_at: last_seen,
816 class_id,
817 strict_write: strict_write != 0,
818 totp_enabled: totp_on != 0,
819 }))
820}
821
822pub fn list_users(conn: &Connection) -> Result<Vec<UserRecord>, String> {
824 let mut stmt = conn
825 .prepare(
826 "SELECT id, username, password_hash, is_owner, created_at, last_seen_at, class_id, strict_write, totp_enabled \
827 FROM users ORDER BY is_owner DESC, created_at ASC",
828 )
829 .map_err(|e| e.to_string())?;
830 let rows: Vec<Result<UserRecord, _>> = stmt
831 .query_map([], |r| {
832 Ok(UserRecord {
833 id: r.get(0)?,
834 username: r.get(1)?,
835 has_password: r.get::<_, Option<String>>(2)?.is_some(),
836 is_owner: r.get::<_, i32>(3)? != 0,
837 created_at: r.get(4)?,
838 last_seen_at: r.get(5)?,
839 class_id: r.get(6)?,
840 strict_write: r.get::<_, i32>(7)? != 0,
841 totp_enabled: r.get::<_, i32>(8)? != 0,
842 })
843 })
844 .map_err(|e| e.to_string())?
845 .collect();
846 rows.into_iter()
847 .map(|r| r.map_err(|e| e.to_string()))
848 .collect()
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize)]
855pub struct TotpStatus {
856 pub enrolled: bool,
858 pub enabled: bool,
860 pub secret: Option<String>,
866 pub uri: Option<String>,
868}
869
870fn refuse_owner(conn: &Connection, user_id: &str) -> Result<(), String> {
879 let is_owner: i32 = conn
880 .query_row(
881 "SELECT is_owner FROM users WHERE id = ?1",
882 rusqlite::params![user_id],
883 |r| r.get(0),
884 )
885 .optional()
886 .map_err(|e| e.to_string())?
887 .ok_or_else(|| "no such user".to_string())?;
888 if is_owner != 0 {
889 return Err(
890 "the vault owner authenticates by deriving the vault key; a second factor \
891 there would gate nothing"
892 .into(),
893 );
894 }
895 Ok(())
896}
897
898pub fn totp_enroll(conn: &Connection, user_id: &str, issuer: &str) -> Result<TotpStatus, String> {
909 refuse_owner(conn, user_id)?;
910 let username: String = conn
911 .query_row(
912 "SELECT username FROM users WHERE id = ?1",
913 rusqlite::params![user_id],
914 |r| r.get(0),
915 )
916 .map_err(|e| e.to_string())?;
917
918 let secret = crate::totp::generate_secret();
919 conn.execute(
920 "UPDATE users SET totp_secret = ?1, totp_enabled = 0, totp_last_step = NULL WHERE id = ?2",
921 rusqlite::params![secret, user_id],
922 )
923 .map_err(|e| e.to_string())?;
924
925 let uri = crate::totp::otpauth_uri(issuer, &username, &secret);
926 Ok(TotpStatus {
927 enrolled: true,
928 enabled: false,
929 secret: Some(secret),
930 uri: Some(uri),
931 })
932}
933
934pub fn totp_confirm(conn: &Connection, user_id: &str, code: &str) -> Result<bool, String> {
946 refuse_owner(conn, user_id)?;
947 let row: Option<(Option<String>, Option<i64>)> = conn
948 .query_row(
949 "SELECT totp_secret, totp_last_step FROM users WHERE id = ?1",
950 rusqlite::params![user_id],
951 |r| Ok((r.get(0)?, r.get(1)?)),
952 )
953 .optional()
954 .map_err(|e| e.to_string())?;
955 let Some((secret, last_step)) = row else {
956 return Err("no such user".into());
957 };
958 let Some(secret) = secret else {
959 return Err("no enrollment in progress; run enroll first".into());
960 };
961 let last = last_step.and_then(|v| u64::try_from(v).ok());
962 let Some(accepted) = crate::totp::verify(&secret, code, last, crate::totp::now_unix()) else {
963 return Ok(false);
964 };
965 conn.execute(
966 "UPDATE users SET totp_enabled = 1, totp_last_step = ?1 WHERE id = ?2",
967 rusqlite::params![accepted.step as i64, user_id],
968 )
969 .map_err(|e| e.to_string())?;
970 Ok(true)
971}
972
973pub fn totp_disable(conn: &Connection, user_id: &str) -> Result<(), String> {
979 conn.execute(
980 "UPDATE users SET totp_secret = NULL, totp_enabled = 0, totp_last_step = NULL \
981 WHERE id = ?1",
982 rusqlite::params![user_id],
983 )
984 .map(|_| ())
985 .map_err(|e| e.to_string())
986}
987
988pub fn totp_status(conn: &Connection, user_id: &str) -> Result<TotpStatus, String> {
991 let (secret, enabled): (Option<String>, i32) = conn
992 .query_row(
993 "SELECT totp_secret, totp_enabled FROM users WHERE id = ?1",
994 rusqlite::params![user_id],
995 |r| Ok((r.get(0)?, r.get(1)?)),
996 )
997 .map_err(|e| e.to_string())?;
998 Ok(TotpStatus {
999 enrolled: secret.is_some(),
1000 enabled: enabled != 0,
1001 secret: None,
1002 uri: None,
1003 })
1004}
1005
1006pub fn totp_required(conn: &Connection, user_id: &str) -> Result<bool, String> {
1008 let enabled: i32 = conn
1009 .query_row(
1010 "SELECT totp_enabled FROM users WHERE id = ?1",
1011 rusqlite::params![user_id],
1012 |r| r.get(0),
1013 )
1014 .optional()
1015 .map_err(|e| e.to_string())?
1016 .unwrap_or(0);
1017 Ok(enabled != 0)
1018}
1019
1020pub fn verify_user_totp(conn: &Connection, user_id: &str, code: &str) -> Result<bool, String> {
1031 let row: Option<(Option<String>, i32, Option<i64>)> = conn
1032 .query_row(
1033 "SELECT totp_secret, totp_enabled, totp_last_step FROM users WHERE id = ?1",
1034 rusqlite::params![user_id],
1035 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1036 )
1037 .optional()
1038 .map_err(|e| e.to_string())?;
1039 let Some((secret, enabled, last_step)) = row else {
1040 return Ok(false);
1041 };
1042 if enabled == 0 {
1043 return Ok(false);
1044 }
1045 let Some(secret) = secret else {
1046 return Ok(false);
1047 };
1048 let last = last_step.and_then(|v| u64::try_from(v).ok());
1049 let Some(accepted) = crate::totp::verify(&secret, code, last, crate::totp::now_unix()) else {
1050 return Ok(false);
1051 };
1052 conn.execute(
1053 "UPDATE users SET totp_last_step = ?1 WHERE id = ?2",
1054 rusqlite::params![accepted.step as i64, user_id],
1055 )
1056 .map_err(|e| e.to_string())?;
1057 Ok(true)
1058}
1059
1060pub fn delete_user(conn: &Connection, user_id: &str) -> Result<(), String> {
1063 let is_owner: Option<i32> = conn
1064 .query_row(
1065 "SELECT is_owner FROM users WHERE id = ?1",
1066 rusqlite::params![user_id],
1067 |r| r.get(0),
1068 )
1069 .optional()
1070 .map_err(|e| e.to_string())?;
1071 if is_owner == Some(1) {
1072 return Err("Cannot delete the owner account".to_string());
1073 }
1074 conn.execute(
1075 "DELETE FROM user_permissions WHERE user_id = ?1",
1076 rusqlite::params![user_id],
1077 )
1078 .map_err(|e| e.to_string())?;
1079 conn.execute(
1080 "DELETE FROM user_tokens WHERE user_id = ?1",
1081 rusqlite::params![user_id],
1082 )
1083 .map_err(|e| e.to_string())?;
1084 conn.execute(
1085 "DELETE FROM users WHERE id = ?1",
1086 rusqlite::params![user_id],
1087 )
1088 .map_err(|e| e.to_string())?;
1089 Ok(())
1090}
1091
1092pub fn list_user_classes(conn: &Connection) -> Result<Vec<UserClass>, String> {
1095 let mut stmt = conn.prepare(
1096 "SELECT id, name, description, cap_manage_users, cap_manage_classes, cap_delete_projects, strict_write, created_at \
1097 FROM user_classes ORDER BY created_at ASC"
1098 ).map_err(|e| e.to_string())?;
1099 let rows: Vec<Result<UserClass, _>> = stmt
1100 .query_map([], |r| {
1101 Ok(UserClass {
1102 id: r.get(0)?,
1103 name: r.get(1)?,
1104 description: r.get(2)?,
1105 cap_manage_users: r.get::<_, i32>(3)? != 0,
1106 cap_manage_classes: r.get::<_, i32>(4)? != 0,
1107 cap_delete_projects: r.get::<_, i32>(5)? != 0,
1108 strict_write: r.get::<_, i32>(6)? != 0,
1109 created_at: r.get(7)?,
1110 })
1111 })
1112 .map_err(|e| e.to_string())?
1113 .collect();
1114 rows.into_iter()
1115 .map(|r| r.map_err(|e| e.to_string()))
1116 .collect()
1117}
1118
1119pub fn create_user_class(
1120 conn: &Connection,
1121 name: &str,
1122 description: &str,
1123 cap_manage_users: bool,
1124 cap_manage_classes: bool,
1125 cap_delete_projects: bool,
1126) -> Result<UserClass, String> {
1127 let id = new_uuid();
1128 let now = iso_now();
1129 conn.execute(
1130 "INSERT INTO user_classes (id, name, description, cap_manage_users, cap_manage_classes, cap_delete_projects, created_at) \
1131 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1132 rusqlite::params![id, name, description, cap_manage_users as i32, cap_manage_classes as i32, cap_delete_projects as i32, now],
1133 ).map_err(|e| e.to_string())?;
1134 Ok(UserClass {
1135 id,
1136 name: name.to_string(),
1137 description: description.to_string(),
1138 cap_manage_users,
1139 cap_manage_classes,
1140 cap_delete_projects,
1141 strict_write: false,
1142 created_at: now,
1143 })
1144}
1145
1146pub fn update_user_class(
1147 conn: &Connection,
1148 class_id: &str,
1149 name: &str,
1150 description: &str,
1151 cap_manage_users: bool,
1152 cap_manage_classes: bool,
1153 cap_delete_projects: bool,
1154) -> Result<(), String> {
1155 conn.execute(
1156 "UPDATE user_classes SET name=?1, description=?2, cap_manage_users=?3, cap_manage_classes=?4, cap_delete_projects=?5 WHERE id=?6",
1157 rusqlite::params![name, description, cap_manage_users as i32, cap_manage_classes as i32, cap_delete_projects as i32, class_id],
1158 ).map(|_| ()).map_err(|e| e.to_string())
1159}
1160
1161pub fn delete_user_class(conn: &Connection, class_id: &str) -> Result<(), String> {
1162 conn.execute(
1164 "UPDATE users SET class_id = NULL WHERE class_id = ?1",
1165 rusqlite::params![class_id],
1166 )
1167 .map_err(|e| e.to_string())?;
1168 conn.execute(
1169 "DELETE FROM user_class_permissions WHERE class_id = ?1",
1170 rusqlite::params![class_id],
1171 )
1172 .map_err(|e| e.to_string())?;
1173 conn.execute(
1174 "DELETE FROM user_classes WHERE id = ?1",
1175 rusqlite::params![class_id],
1176 )
1177 .map(|_| ())
1178 .map_err(|e| e.to_string())
1179}
1180
1181pub fn get_class_permissions(
1182 conn: &Connection,
1183 class_id: &str,
1184) -> Result<Vec<ClassPermission>, String> {
1185 let mut stmt = conn.prepare(
1186 "SELECT class_id, scope_type, scope_value, permission FROM user_class_permissions WHERE class_id = ?1"
1187 ).map_err(|e| e.to_string())?;
1188 let rows: Vec<Result<ClassPermission, _>> = stmt
1189 .query_map(rusqlite::params![class_id], |r| {
1190 Ok(ClassPermission {
1191 class_id: r.get(0)?,
1192 scope_type: r.get(1)?,
1193 scope_value: r.get(2)?,
1194 permission: r.get(3)?,
1195 })
1196 })
1197 .map_err(|e| e.to_string())?
1198 .collect();
1199 rows.into_iter()
1200 .map(|r| r.map_err(|e| e.to_string()))
1201 .collect()
1202}
1203
1204pub fn set_class_permissions(
1205 conn: &Connection,
1206 class_id: &str,
1207 permissions: &[ClassPermission],
1208) -> Result<(), String> {
1209 conn.execute(
1210 "DELETE FROM user_class_permissions WHERE class_id = ?1",
1211 rusqlite::params![class_id],
1212 )
1213 .map_err(|e| e.to_string())?;
1214 for p in permissions {
1215 conn.execute(
1216 "INSERT INTO user_class_permissions (class_id, scope_type, scope_value, permission) VALUES (?1, ?2, ?3, ?4)",
1217 rusqlite::params![class_id, p.scope_type, p.scope_value, p.permission],
1218 ).map_err(|e| e.to_string())?;
1219 }
1220 Ok(())
1221}
1222
1223pub fn assign_user_class(
1224 conn: &Connection,
1225 user_id: &str,
1226 class_id: Option<&str>,
1227) -> Result<(), String> {
1228 conn.execute(
1229 "UPDATE users SET class_id = ?1 WHERE id = ?2",
1230 rusqlite::params![class_id, user_id],
1231 )
1232 .map(|_| ())
1233 .map_err(|e| e.to_string())
1234}
1235
1236pub fn get_user_capabilities(
1238 conn: &Connection,
1239 user_id: &str,
1240) -> Result<(bool, bool, bool), String> {
1241 let class_id: Option<String> = conn
1242 .query_row(
1243 "SELECT class_id FROM users WHERE id = ?1",
1244 rusqlite::params![user_id],
1245 |r| r.get(0),
1246 )
1247 .optional()
1248 .map_err(|e| e.to_string())?
1249 .flatten();
1250 if let Some(cid) = class_id {
1251 let row: Option<(i32, i32, i32)> = conn
1252 .query_row(
1253 "SELECT cap_manage_users, cap_manage_classes, cap_delete_projects FROM user_classes WHERE id = ?1",
1254 rusqlite::params![cid],
1255 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1256 ).optional().map_err(|e| e.to_string())?;
1257 if let Some((u, c, d)) = row {
1258 return Ok((u != 0, c != 0, d != 0));
1259 }
1260 }
1261 Ok((false, false, false))
1262}
1263
1264pub fn authority_tier(is_owner: bool, cap_manage_users: bool, cap_manage_classes: bool) -> i32 {
1272 if is_owner {
1273 3
1274 } else if cap_manage_classes {
1275 2
1276 } else if cap_manage_users {
1277 1
1278 } else {
1279 0
1280 }
1281}
1282
1283pub fn class_authority_tier(conn: &Connection, class_id: &str) -> Result<i32, String> {
1285 let row: Option<(i32, i32)> = conn
1286 .query_row(
1287 "SELECT cap_manage_users, cap_manage_classes FROM user_classes WHERE id = ?1",
1288 rusqlite::params![class_id],
1289 |r| Ok((r.get(0)?, r.get(1)?)),
1290 )
1291 .optional()
1292 .map_err(|e| e.to_string())?;
1293 Ok(row
1294 .map(|(u, c)| authority_tier(false, u != 0, c != 0))
1295 .unwrap_or(0))
1296}
1297
1298pub fn token_user_id(conn: &Connection, token_id: &str) -> Result<Option<String>, String> {
1300 conn.query_row(
1301 "SELECT user_id FROM user_tokens WHERE id = ?1",
1302 rusqlite::params![token_id],
1303 |r| r.get(0),
1304 )
1305 .optional()
1306 .map_err(|e| e.to_string())
1307}
1308
1309pub fn user_authority_tier(conn: &Connection, user_id: &str) -> Result<i32, String> {
1312 let is_owner: Option<i32> = conn
1313 .query_row(
1314 "SELECT is_owner FROM users WHERE id = ?1",
1315 rusqlite::params![user_id],
1316 |r| r.get(0),
1317 )
1318 .optional()
1319 .map_err(|e| e.to_string())?;
1320 let Some(owner_flag) = is_owner else {
1321 return Ok(0);
1322 };
1323 let (mu, mc, _) = get_user_capabilities(conn, user_id)?;
1324 Ok(authority_tier(owner_flag != 0, mu, mc))
1325}
1326
1327pub fn ensure_owner_user(conn: &Connection) -> Result<String, String> {
1341 if let Some(id) = conn
1342 .query_row("SELECT id FROM users WHERE is_owner = 1 LIMIT 1", [], |r| {
1343 r.get::<_, String>(0)
1344 })
1345 .optional()
1346 .map_err(|e| e.to_string())?
1347 {
1348 return Ok(id);
1349 }
1350
1351 let taken: bool = conn
1353 .query_row("SELECT 1 FROM users WHERE username = 'owner'", [], |r| {
1354 r.get::<_, i32>(0)
1355 })
1356 .optional()
1357 .map_err(|e| e.to_string())?
1358 .is_some();
1359 let id = new_uuid();
1360 let username = if taken {
1361 format!("owner-{}", &id[..8])
1362 } else {
1363 "owner".to_string()
1364 };
1365
1366 conn.execute(
1367 "INSERT INTO users (id, username, password_hash, is_owner, created_at) \
1368 VALUES (?1, ?2, NULL, 1, ?3)",
1369 rusqlite::params![id, username, iso_now()],
1370 )
1371 .map_err(|e| e.to_string())?;
1372 Ok(id)
1373}
1374
1375pub enum AdminSeed {
1377 Exists,
1379 Created { generated_password: Option<String> },
1383}
1384
1385pub fn seed_default_admin(
1389 conn: &Connection,
1390 env_password: Option<&str>,
1391) -> Result<AdminSeed, String> {
1392 let exists: Option<String> = conn
1393 .query_row("SELECT id FROM users WHERE username = 'admin'", [], |r| {
1394 r.get(0)
1395 })
1396 .optional()
1397 .map_err(|e| e.to_string())?;
1398 if exists.is_some() {
1399 return Ok(AdminSeed::Exists);
1400 }
1401
1402 let (password, generated) = match env_password {
1403 Some(p) if !p.is_empty() => (p.to_string(), None),
1404 _ => {
1405 let mut raw = [0u8; 16];
1406 rand::thread_rng().fill_bytes(&mut raw);
1407 let pw = hex::encode(raw);
1408 (pw.clone(), Some(pw))
1409 }
1410 };
1411
1412 let user = create_user(conn, "admin", Some(&password), false)?;
1413 assign_user_class(conn, &user.id, Some("cls-admin"))?;
1414 Ok(AdminSeed::Created {
1415 generated_password: generated,
1416 })
1417}
1418
1419pub fn create_user_token(
1424 conn: &Connection,
1425 user_id: &str,
1426 description: Option<&str>,
1427 expires_at: Option<&str>,
1428) -> Result<(String, String), String> {
1429 let token_id = new_uuid();
1430 let mut raw = [0u8; 32];
1431 rand::thread_rng().fill_bytes(&mut raw);
1432 let plaintext = hex::encode(raw);
1433 let token_hash = sha256_hex(&plaintext);
1434 conn.execute(
1435 "INSERT INTO user_tokens (id, token_hash, user_id, description, created_at, expires_at) \
1436 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1437 rusqlite::params![
1438 token_id,
1439 token_hash,
1440 user_id,
1441 description,
1442 iso_now(),
1443 expires_at
1444 ],
1445 )
1446 .map_err(|e| e.to_string())?;
1447 Ok((token_id, plaintext))
1448}
1449
1450pub fn revoke_user_token(conn: &Connection, token_id: &str) -> Result<(), String> {
1452 conn.execute(
1453 "DELETE FROM user_tokens WHERE id = ?1",
1454 rusqlite::params![token_id],
1455 )
1456 .map(|_| ())
1457 .map_err(|e| e.to_string())
1458}
1459
1460pub fn list_user_tokens(conn: &Connection, user_id: &str) -> Result<Vec<TokenRecord>, String> {
1462 let mut stmt = conn
1463 .prepare(
1464 "SELECT id, user_id, description, created_at, expires_at \
1465 FROM user_tokens WHERE user_id = ?1 ORDER BY created_at",
1466 )
1467 .map_err(|e| e.to_string())?;
1468 let rows: Vec<Result<TokenRecord, _>> = stmt
1469 .query_map(rusqlite::params![user_id], |r| {
1470 Ok(TokenRecord {
1471 id: r.get(0)?,
1472 user_id: r.get(1)?,
1473 description: r.get(2)?,
1474 created_at: r.get(3)?,
1475 expires_at: r.get(4)?,
1476 })
1477 })
1478 .map_err(|e| e.to_string())?
1479 .collect();
1480 rows.into_iter()
1481 .map(|r| r.map_err(|e| e.to_string()))
1482 .collect()
1483}
1484
1485pub fn get_user_permissions(
1489 conn: &Connection,
1490 user_id: &str,
1491) -> Result<Vec<PermissionRecord>, String> {
1492 let mut stmt = conn
1493 .prepare(
1494 "SELECT user_id, scope_type, scope_value, permission \
1495 FROM user_permissions WHERE user_id = ?1 \
1496 ORDER BY scope_type, scope_value",
1497 )
1498 .map_err(|e| e.to_string())?;
1499 let rows: Vec<Result<PermissionRecord, _>> = stmt
1500 .query_map(rusqlite::params![user_id], |r| {
1501 Ok(PermissionRecord {
1502 user_id: r.get(0)?,
1503 scope_type: r.get(1)?,
1504 scope_value: r.get(2)?,
1505 permission: r.get(3)?,
1506 })
1507 })
1508 .map_err(|e| e.to_string())?
1509 .collect();
1510 rows.into_iter()
1511 .map(|r| r.map_err(|e| e.to_string()))
1512 .collect()
1513}
1514
1515pub fn set_user_permissions(
1517 conn: &Connection,
1518 user_id: &str,
1519 permissions: &[PermissionRecord],
1520) -> Result<(), String> {
1521 conn.execute(
1522 "DELETE FROM user_permissions WHERE user_id = ?1",
1523 rusqlite::params![user_id],
1524 )
1525 .map_err(|e| e.to_string())?;
1526 for p in permissions {
1527 conn.execute(
1528 "INSERT INTO user_permissions (user_id, scope_type, scope_value, permission) \
1529 VALUES (?1, ?2, ?3, ?4)",
1530 rusqlite::params![user_id, p.scope_type, p.scope_value, p.permission],
1531 )
1532 .map_err(|e| e.to_string())?;
1533 }
1534 Ok(())
1535}
1536
1537pub fn glob_matches(pattern: &str, value: &str) -> bool {
1541 let (pb, vb) = (pattern.as_bytes(), value.as_bytes());
1542 let (mut pi, mut vi) = (0usize, 0usize);
1543 let (mut star_pi, mut star_vi) = (usize::MAX, 0usize);
1544 while vi < vb.len() {
1545 if pi < pb.len() && pb[pi] == b'*' {
1546 star_pi = pi;
1547 star_vi = vi;
1548 pi += 1;
1549 } else if pi < pb.len() && (pb[pi] == vb[vi] || pb[pi] == b'?') {
1550 pi += 1;
1551 vi += 1;
1552 } else if star_pi != usize::MAX {
1553 pi = star_pi + 1;
1554 star_vi += 1;
1555 vi = star_vi;
1556 } else {
1557 return false;
1558 }
1559 }
1560 while pi < pb.len() && pb[pi] == b'*' {
1561 pi += 1;
1562 }
1563 pi == pb.len()
1564}
1565
1566fn build_project_names(vault: &serde_json::Value) -> HashMap<String, String> {
1567 vault
1568 .get("projects")
1569 .and_then(|v| v.as_array())
1570 .map(|arr| {
1571 arr.iter()
1572 .filter_map(|p| {
1573 Some((
1574 p.get("id")?.as_str()?.to_string(),
1575 p.get("name")?.as_str()?.to_string(),
1576 ))
1577 })
1578 .collect()
1579 })
1580 .unwrap_or_default()
1581}
1582
1583pub fn filter_vault_for_user(
1589 vault: serde_json::Value,
1590 read: Option<&crate::permex::Expr>,
1591) -> serde_json::Value {
1592 let Some(expr) = read else {
1593 return serde_json::json!({ "api_keys": [], "user_categories": [], "projects": [] });
1594 };
1595
1596 let project_names = build_project_names(&vault);
1597 let empty = vec![];
1598 let api_keys = vault
1599 .get("api_keys")
1600 .and_then(|v| v.as_array())
1601 .unwrap_or(&empty);
1602
1603 let mut visible: Vec<serde_json::Value> = api_keys
1604 .iter()
1605 .filter(|e| {
1606 crate::permex::eval(
1607 expr,
1608 &crate::permex::EntryView::from_entry(e, &project_names),
1609 )
1610 })
1611 .cloned()
1612 .collect();
1613
1614 let visible_bundle_ids: HashSet<String> = visible
1619 .iter()
1620 .filter(|entry| {
1621 entry.get("secretType").and_then(serde_json::Value::as_str) == Some("bundle")
1622 })
1623 .filter_map(|entry| {
1624 entry
1625 .get("id")
1626 .and_then(serde_json::Value::as_str)
1627 .map(str::to_owned)
1628 })
1629 .collect();
1630 for entry in &mut visible {
1631 let Some(bundle_id) = entry.get("bundle_id").and_then(serde_json::Value::as_str) else {
1632 continue;
1633 };
1634 if !visible_bundle_ids.contains(bundle_id) {
1635 if let Some(object) = entry.as_object_mut() {
1636 object.remove("bundle_id");
1637 object.remove("bundle_slot");
1638 object.remove("bundle_order");
1639 }
1640 }
1641 }
1642
1643 let visible_pids: HashSet<String> = visible
1644 .iter()
1645 .flat_map(|e| {
1646 e.get("projectIds")
1647 .and_then(|v| v.as_array())
1648 .unwrap_or(&empty)
1649 .iter()
1650 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1651 })
1652 .collect();
1653
1654 let projects = vault
1655 .get("projects")
1656 .and_then(|v| v.as_array())
1657 .unwrap_or(&empty);
1658 let visible_projects: Vec<serde_json::Value> = projects
1659 .iter()
1660 .filter(|p| {
1661 p.get("id")
1662 .and_then(|v| v.as_str())
1663 .is_some_and(|id| id == "Universal" || visible_pids.contains(id))
1664 })
1665 .cloned()
1666 .collect();
1667
1668 let visible_cats: HashSet<String> = visible
1672 .iter()
1673 .flat_map(|e| {
1674 e.get("categories")
1675 .and_then(|v| v.as_array())
1676 .unwrap_or(&empty)
1677 .iter()
1678 .filter_map(|v| v.as_str())
1679 })
1680 .flat_map(|c| {
1681 let parts: Vec<&str> = c.split('/').collect();
1683 (1..=parts.len()).map(move |n| parts[..n].join("/"))
1684 })
1685 .collect();
1686 let user_categories: Vec<serde_json::Value> = vault
1687 .get("user_categories")
1688 .and_then(|v| v.as_array())
1689 .unwrap_or(&empty)
1690 .iter()
1691 .filter(|c| c.as_str().is_some_and(|s| visible_cats.contains(s)))
1692 .cloned()
1693 .collect();
1694
1695 serde_json::json!({
1696 "api_keys": visible,
1697 "user_categories": user_categories,
1698 "projects": visible_projects,
1699 })
1700}
1701
1702fn project_write_allowed(
1712 pid: &str,
1713 full_keys: &[serde_json::Value],
1714 writable: &dyn Fn(&serde_json::Value) -> bool,
1715) -> bool {
1716 full_keys
1717 .iter()
1718 .filter(|e| {
1719 e.get("projectIds")
1720 .and_then(|v| v.as_array())
1721 .is_some_and(|a| a.iter().any(|v| v.as_str() == Some(pid)))
1722 })
1723 .all(writable)
1724}
1725
1726fn category_write_allowed(
1728 cat: &str,
1729 full_keys: &[serde_json::Value],
1730 writable: &dyn Fn(&serde_json::Value) -> bool,
1731) -> bool {
1732 full_keys
1733 .iter()
1734 .filter(|e| {
1735 e.get("categories")
1736 .and_then(|v| v.as_array())
1737 .is_some_and(|a| a.iter().any(|v| v.as_str() == Some(cat)))
1738 })
1739 .all(writable)
1740}
1741
1742fn merge_named_collection(
1751 full: &[serde_json::Value],
1752 submitted: &[serde_json::Value],
1753 served: &HashSet<String>,
1754 key_of: &dyn Fn(&serde_json::Value) -> Option<String>,
1755 allowed: &dyn Fn(&str) -> bool,
1756 what: &str,
1757) -> Result<Vec<serde_json::Value>, String> {
1758 let sub_map: HashMap<String, &serde_json::Value> = submitted
1759 .iter()
1760 .filter_map(|v| key_of(v).map(|k| (k, v)))
1761 .collect();
1762
1763 let mut out: Vec<serde_json::Value> = Vec::new();
1764 for item in full {
1765 let Some(k) = key_of(item) else {
1766 out.push(item.clone());
1767 continue;
1768 };
1769 if !served.contains(&k) {
1770 out.push(item.clone());
1772 continue;
1773 }
1774 match sub_map.get(&k) {
1775 Some(&sub) if sub == item => out.push(item.clone()),
1776 Some(&sub) => {
1777 if !allowed(&k) {
1778 return Err(format!("Write permission denied for {what} '{k}'"));
1779 }
1780 out.push(sub.clone());
1781 }
1782 None => {
1783 if !allowed(&k) {
1784 return Err(format!("Delete permission denied for {what} '{k}'"));
1785 }
1786 }
1788 }
1789 }
1790
1791 let full_keys: HashSet<String> = full.iter().filter_map(key_of).collect();
1795 for item in submitted {
1796 if let Some(k) = key_of(item) {
1797 if !full_keys.contains(&k) {
1798 out.push(item.clone());
1799 }
1800 }
1801 }
1802 Ok(out)
1803}
1804
1805pub fn merge_user_vault_write(
1825 full_vault: serde_json::Value,
1826 user_data: serde_json::Value,
1827 read: Option<&crate::permex::Expr>,
1828 write: Option<&crate::permex::Expr>,
1829) -> Result<serde_json::Value, String> {
1830 let project_names = build_project_names(&full_vault);
1831 let Some(write_expr) = write else {
1832 return Err("No write permissions".to_string());
1833 };
1834 let writable = |e: &serde_json::Value| {
1835 crate::permex::eval(
1836 write_expr,
1837 &crate::permex::EntryView::from_entry(e, &project_names),
1838 )
1839 };
1840
1841 let empty = vec![];
1842 let full_keys = full_vault
1843 .get("api_keys")
1844 .and_then(|v| v.as_array())
1845 .unwrap_or(&empty);
1846 let user_keys = user_data
1847 .get("api_keys")
1848 .and_then(|v| v.as_array())
1849 .unwrap_or(&empty);
1850 let readable = |entry: &serde_json::Value| {
1851 read.is_some_and(|expr| {
1852 crate::permex::eval(
1853 expr,
1854 &crate::permex::EntryView::from_entry(entry, &project_names),
1855 )
1856 })
1857 };
1858 let find_bundle = |id: &str| {
1859 full_keys.iter().chain(user_keys.iter()).find(|candidate| {
1860 candidate.get("id").and_then(serde_json::Value::as_str) == Some(id)
1861 && candidate
1862 .get("secretType")
1863 .and_then(serde_json::Value::as_str)
1864 == Some("bundle")
1865 })
1866 };
1867
1868 use crate::entry_ck;
1873
1874 for entry in user_keys {
1876 if !writable(entry) {
1877 return Err(format!(
1878 "Write permission denied for '{}'",
1879 entry
1880 .get("provider")
1881 .and_then(|v| v.as_str())
1882 .unwrap_or("?")
1883 ));
1884 }
1885 let id = entry_ck(entry);
1886 let prior = full_keys.iter().find(|old| entry_ck(old) == id);
1887 let old_bundle = prior
1888 .and_then(|old| old.get("bundle_id"))
1889 .and_then(serde_json::Value::as_str);
1890 let new_bundle = entry.get("bundle_id").and_then(serde_json::Value::as_str);
1891 let hidden_parent_was_omitted = old_bundle
1892 .and_then(find_bundle)
1893 .is_some_and(|bundle| !readable(bundle))
1894 && entry.get("bundle_id").is_none();
1895 let effective_new_bundle = if hidden_parent_was_omitted {
1896 old_bundle
1897 } else {
1898 new_bundle
1899 };
1900 let old_slot = prior.and_then(|old| old.get("bundle_slot"));
1901 let new_slot = entry.get("bundle_slot");
1902 let old_order = prior.and_then(|old| old.get("bundle_order"));
1903 let new_order = entry.get("bundle_order");
1904 let association_unchanged =
1905 hidden_parent_was_omitted && new_slot.is_none() && new_order.is_none()
1906 || old_slot == new_slot && old_order == new_order;
1907 if old_bundle != effective_new_bundle || !association_unchanged {
1908 for bundle_id in [old_bundle, effective_new_bundle].into_iter().flatten() {
1909 let bundle = find_bundle(bundle_id);
1910 if bundle.is_none() && Some(bundle_id) == old_bundle {
1913 continue;
1914 }
1915 if !bundle.is_some_and(writable) {
1916 return Err(format!(
1917 "Write permission denied for bundle membership in '{bundle_id}'"
1918 ));
1919 }
1920 }
1921 }
1922 }
1923
1924 let user_map: HashMap<String, &serde_json::Value> =
1925 user_keys.iter().map(|e| (entry_ck(e), e)).collect();
1926
1927 let user_writable_cks: HashSet<String> = full_keys
1928 .iter()
1929 .filter(|e| writable(e))
1930 .map(entry_ck)
1931 .collect();
1932
1933 let full_cks: HashSet<String> = full_keys.iter().map(entry_ck).collect();
1934
1935 let mut result: Vec<serde_json::Value> = Vec::new();
1936
1937 for entry in full_keys {
1939 let ck = entry_ck(entry);
1940 if user_writable_cks.contains(&ck) {
1941 if let Some(&user_entry) = user_map.get(&ck) {
1942 let hidden_parent_was_omitted = entry
1943 .get("bundle_id")
1944 .and_then(serde_json::Value::as_str)
1945 .and_then(find_bundle)
1946 .is_some_and(|bundle| !readable(bundle))
1947 && user_entry.get("bundle_id").is_none();
1948 if hidden_parent_was_omitted {
1949 let mut merged = user_entry.clone();
1950 for key in ["bundle_id", "bundle_slot", "bundle_order"] {
1951 if let Some(value) = entry.get(key) {
1952 merged[key] = value.clone();
1953 }
1954 }
1955 result.push(merged);
1956 } else {
1957 result.push(user_entry.clone());
1958 }
1959 }
1960 } else {
1962 result.push(entry.clone());
1963 }
1964 }
1965
1966 for entry in user_keys {
1968 if !full_cks.contains(&entry_ck(entry)) {
1969 result.push(entry.clone());
1970 }
1971 }
1972
1973 let served = filter_vault_for_user(full_vault.clone(), read);
1980 let served_projects: HashSet<String> = served
1981 .get("projects")
1982 .and_then(|v| v.as_array())
1983 .unwrap_or(&empty)
1984 .iter()
1985 .filter_map(|p| p.get("id").and_then(|v| v.as_str()).map(str::to_string))
1986 .collect();
1987 let served_cats: HashSet<String> = served
1988 .get("user_categories")
1989 .and_then(|v| v.as_array())
1990 .unwrap_or(&empty)
1991 .iter()
1992 .filter_map(|c| c.as_str().map(str::to_string))
1993 .collect();
1994
1995 let full_projects = full_vault
2000 .get("projects")
2001 .and_then(|v| v.as_array())
2002 .cloned()
2003 .unwrap_or_default();
2004 let merged_projects = match user_data.get("projects").and_then(|v| v.as_array()) {
2005 None => full_projects.clone(),
2006 Some(user_projects) => merge_named_collection(
2007 &full_projects,
2008 user_projects,
2009 &served_projects,
2010 &|p| p.get("id").and_then(|v| v.as_str()).map(str::to_string),
2011 &|pid| project_write_allowed(pid, full_keys, &writable),
2012 "project",
2013 )?,
2014 };
2015
2016 let full_cats = full_vault
2017 .get("user_categories")
2018 .and_then(|v| v.as_array())
2019 .cloned()
2020 .unwrap_or_default();
2021 let merged_cats = match user_data.get("user_categories").and_then(|v| v.as_array()) {
2022 None => full_cats.clone(),
2023 Some(user_cats) => merge_named_collection(
2024 &full_cats,
2025 user_cats,
2026 &served_cats,
2027 &|c| c.as_str().map(str::to_string),
2028 &|cat| category_write_allowed(cat, full_keys, &writable),
2029 "category",
2030 )?,
2031 };
2032
2033 let mut out = full_vault.clone();
2034 if let Some(obj) = out.as_object_mut() {
2035 obj.insert("api_keys".to_string(), serde_json::Value::Array(result));
2036 obj.insert(
2037 "projects".to_string(),
2038 serde_json::Value::Array(merged_projects),
2039 );
2040 obj.insert(
2041 "user_categories".to_string(),
2042 serde_json::Value::Array(merged_cats),
2043 );
2044 }
2045 Ok(out)
2046}
2047
2048#[cfg(test)]
2051mod tests {
2052 use super::*;
2053 use serde_json::json;
2054
2055 #[test]
2058 fn glob_exact_and_wildcards() {
2059 assert!(glob_matches("*", "anything"));
2060 assert!(glob_matches("*", ""));
2061 assert!(glob_matches("Cloud/AWS", "Cloud/AWS"));
2062 assert!(!glob_matches("Cloud/AWS", "Cloud/GCP"));
2063 assert!(glob_matches("wg0-*", "wg0-office"));
2064 assert!(!glob_matches("wg0-*", "wg1-office"));
2065 assert!(glob_matches("*-prod", "app-prod"));
2066 assert!(glob_matches("a?c", "abc"));
2067 assert!(!glob_matches("a?c", "ac"));
2068 assert!(glob_matches("a*b*c", "axxbyyc"));
2069 }
2070
2071 #[test]
2072 fn glob_does_not_match_prefix_by_accident() {
2073 assert!(!glob_matches("Cloud", "CloudSecrets"));
2075 assert!(glob_matches("Cloud*", "CloudSecrets"));
2076 }
2077
2078 #[test]
2081 fn argon2_hash_roundtrips_and_rejects_wrong_password() {
2082 let stored = hash_password("correct horse");
2083 assert!(
2084 stored.starts_with("$argon2id$"),
2085 "must emit PHC format, got {stored}"
2086 );
2087 assert!(verify_password_hash("correct horse", &stored));
2088 assert!(!verify_password_hash("wrong horse", &stored));
2089 }
2090
2091 #[test]
2092 fn hashes_are_salted_per_call() {
2093 assert_ne!(
2094 hash_password("same"),
2095 hash_password("same"),
2096 "identical passwords must not produce identical hashes"
2097 );
2098 }
2099
2100 #[test]
2101 fn legacy_sha256_hashes_still_verify() {
2102 let salt = [0xABu8; 16];
2104 let mut h = Sha256::new();
2105 h.update(salt);
2106 h.update(b"legacy-pass");
2107 let legacy = format!("{}:{}", hex::encode(salt), hex::encode(h.finalize()));
2108 assert!(verify_password_hash("legacy-pass", &legacy));
2109 assert!(!verify_password_hash("nope", &legacy));
2110 }
2111
2112 #[test]
2113 fn malformed_hashes_are_rejected_not_accepted() {
2114 assert!(!verify_password_hash("x", ""));
2115 assert!(!verify_password_hash("x", "garbage"));
2116 assert!(!verify_password_hash("x", "$argon2id$broken"));
2117 assert!(!verify_password_hash("x", "nothex:alsonothex"));
2118 }
2119
2120 #[test]
2123 fn authority_tiers_are_ordered() {
2124 let owner = authority_tier(true, false, false);
2125 let admin = authority_tier(false, true, true);
2126 let moder = authority_tier(false, true, false);
2127 let user = authority_tier(false, false, false);
2128 assert!(
2129 owner > admin && admin > moder && moder > user,
2130 "owner {owner} > admin {admin} > moderator {moder} > user {user}"
2131 );
2132 }
2133
2134 fn ex(src: &str) -> crate::permex::Expr {
2140 crate::permex::parse(src).unwrap()
2141 }
2142
2143 fn scratch_conn(tag: &str) -> Connection {
2146 let nanos = std::time::SystemTime::now()
2147 .duration_since(std::time::UNIX_EPOCH)
2148 .unwrap()
2149 .as_nanos();
2150 let dir = std::env::temp_dir().join(format!("unenverse-users-{tag}-{nanos}"));
2151 std::fs::create_dir_all(&dir).unwrap();
2152 let key = crate::derive_key("pw", b"0123456789abcdef").unwrap();
2153 let conn = crate::open_db(&dir.join("vault.db"), &key).unwrap();
2154 crate::init_schema(&conn).unwrap();
2155 conn
2156 }
2157
2158 #[test]
2159 fn owner_row_is_created_once_and_is_idempotent() {
2160 let conn = scratch_conn("owner");
2161 let a = ensure_owner_user(&conn).unwrap();
2162 let b = ensure_owner_user(&conn).unwrap();
2163 assert_eq!(a, b, "must not create a second owner");
2164 let owners: i32 = conn
2165 .query_row("SELECT COUNT(*) FROM users WHERE is_owner = 1", [], |r| {
2166 r.get(0)
2167 })
2168 .unwrap();
2169 assert_eq!(owners, 1);
2170 }
2171
2172 #[test]
2173 fn owner_row_has_no_password_and_cannot_be_logged_into() {
2174 let conn = scratch_conn("owner-nopw");
2175 ensure_owner_user(&conn).unwrap();
2176 let hash: Option<String> = conn
2179 .query_row(
2180 "SELECT password_hash FROM users WHERE is_owner = 1",
2181 [],
2182 |r| r.get(0),
2183 )
2184 .unwrap();
2185 assert!(hash.is_none(), "owner row must have a NULL password_hash");
2186 assert!(verify_user_password(&conn, "owner", "anything")
2187 .unwrap()
2188 .is_none());
2189 assert!(verify_user_password(&conn, "owner", "").unwrap().is_none());
2190 }
2191
2192 #[test]
2193 fn passwordless_user_is_a_credential_failure_not_an_error() {
2194 let conn = scratch_conn("nopw");
2197 create_user(&conn, "tokenonly", None, false).unwrap();
2198 let existing = verify_user_password(&conn, "tokenonly", "guess");
2199 let missing = verify_user_password(&conn, "ghost", "guess");
2200 assert!(existing.is_ok() && existing.unwrap().is_none());
2201 assert!(missing.is_ok() && missing.unwrap().is_none());
2202 }
2203
2204 #[test]
2208 fn unknown_and_passwordless_usernames_pay_for_an_argon2_run() {
2209 let conn = scratch_conn("dummyverify");
2210 create_user(&conn, "tokenonly", None, false).unwrap();
2211 create_user(&conn, "real", Some("pw-real"), false).unwrap();
2212 let ran = || DUMMY_VERIFIES.with(|c| c.get());
2213
2214 let before = ran();
2215 assert!(verify_user_password(&conn, "ghost", "x").unwrap().is_none());
2216 assert_eq!(
2217 ran(),
2218 before + 1,
2219 "unknown username skipped the dummy verify"
2220 );
2221
2222 let before = ran();
2223 assert!(verify_user_password(&conn, "tokenonly", "x")
2224 .unwrap()
2225 .is_none());
2226 assert_eq!(
2227 ran(),
2228 before + 1,
2229 "password-less user skipped the dummy verify"
2230 );
2231
2232 let before = ran();
2234 assert!(verify_user_password(&conn, "real", "wrong")
2235 .unwrap()
2236 .is_none());
2237 assert!(
2238 verify_user_password(&conn, "real", "pw-real")
2239 .unwrap()
2240 .is_some(),
2241 "the real user still logs in"
2242 );
2243 assert_eq!(ran(), before, "a real user must not trigger the dummy path");
2244 }
2245
2246 #[test]
2250 #[ignore]
2251 fn unknown_username_timing() {
2252 let conn = scratch_conn("timing");
2253 create_user(&conn, "real", Some("pw-real"), false).unwrap();
2254 let mean = |name: &str| {
2255 let t = std::time::Instant::now();
2256 for _ in 0..50 {
2257 let _ = verify_user_password(&conn, name, "wrong-guess");
2258 }
2259 t.elapsed().as_secs_f64() * 1000.0 / 50.0
2260 };
2261 let (known, unknown) = (mean("real"), mean("ghost"));
2262 println!(
2263 "known {known:.1} ms, unknown {unknown:.1} ms, ratio {:.3}",
2264 unknown / known
2265 );
2266 assert!((unknown / known - 1.0).abs() < 0.10, "outside 10%");
2267 }
2268
2269 #[test]
2270 fn owner_username_collision_falls_back() {
2271 let conn = scratch_conn("collide");
2272 create_user(&conn, "owner", Some("pw"), false).unwrap();
2273 let id = ensure_owner_user(&conn).unwrap();
2274 let uname: String = conn
2275 .query_row(
2276 "SELECT username FROM users WHERE id = ?1",
2277 rusqlite::params![id],
2278 |r| r.get(0),
2279 )
2280 .unwrap();
2281 assert_ne!(
2282 uname, "owner",
2283 "must not collide with the existing UNIQUE username"
2284 );
2285 assert!(uname.starts_with("owner-"));
2286 }
2287
2288 #[test]
2289 fn owner_outranks_every_class() {
2290 let conn = scratch_conn("tier");
2291 let id = ensure_owner_user(&conn).unwrap();
2292 assert_eq!(user_authority_tier(&conn, &id).unwrap(), 3);
2293 }
2294
2295 #[test]
2298 fn legacy_rows_are_migrated_into_expressions() {
2299 let conn = scratch_conn("permmig");
2300 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2301 set_user_permissions(
2302 &conn,
2303 &u.id,
2304 &[
2305 PermissionRecord {
2306 user_id: u.id.clone(),
2307 scope_type: "project".into(),
2308 scope_value: "Alpha".into(),
2309 permission: "write".into(),
2310 },
2311 PermissionRecord {
2312 user_id: u.id.clone(),
2313 scope_type: "category".into(),
2314 scope_value: "dev".into(),
2315 permission: "read".into(),
2316 },
2317 ],
2318 )
2319 .unwrap();
2320 conn.execute(
2322 "DELETE FROM vault_meta WHERE key = 'perm_expr_migrated'",
2323 [],
2324 )
2325 .unwrap();
2326 migrate_rows_to_expressions(&conn).unwrap();
2327
2328 let read = get_permission_expr(&conn, "user", &u.id, "read")
2329 .unwrap()
2330 .unwrap();
2331 let write = get_permission_expr(&conn, "user", &u.id, "write")
2332 .unwrap()
2333 .unwrap();
2334 assert!(
2335 read.contains("project:Alpha") && read.contains("category:dev"),
2336 "read should be the OR of every row, got {read}"
2337 );
2338 assert!(
2339 write.contains("project:Alpha") && !write.contains("category:dev"),
2340 "write should only include write rows, got {write}"
2341 );
2342 }
2343
2344 #[test]
2345 fn migration_runs_once_only() {
2346 let conn = scratch_conn("permmig-once");
2347 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2348 set_permission_expr(&conn, "user", &u.id, "read", "tag:mine").unwrap();
2349 set_user_permissions(
2350 &conn,
2351 &u.id,
2352 &[PermissionRecord {
2353 user_id: u.id.clone(),
2354 scope_type: "vault".into(),
2355 scope_value: "*".into(),
2356 permission: "write".into(),
2357 }],
2358 )
2359 .unwrap();
2360 migrate_rows_to_expressions(&conn).unwrap();
2361 assert_eq!(
2364 get_permission_expr(&conn, "user", &u.id, "read")
2365 .unwrap()
2366 .unwrap(),
2367 "tag:mine"
2368 );
2369 }
2370
2371 #[test]
2372 fn built_in_classes_get_expressions_on_a_fresh_vault() {
2373 let conn = scratch_conn("permmig-seed");
2376 let admin = get_permission_expr(&conn, "class", "cls-admin", "write").unwrap();
2377 let viewer_read = get_permission_expr(&conn, "class", "cls-viewer", "read").unwrap();
2378 assert_eq!(admin.as_deref(), Some("vault:*"));
2379 assert_eq!(viewer_read.as_deref(), Some("vault:*"));
2380 assert!(
2381 get_permission_expr(&conn, "class", "cls-viewer", "write")
2382 .unwrap()
2383 .is_none(),
2384 "Viewer is read-only"
2385 );
2386 }
2387
2388 #[test]
2389 fn invalid_expressions_are_rejected_on_save() {
2390 let conn = scratch_conn("permbad");
2391 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2392 assert!(set_permission_expr(&conn, "user", &u.id, "read", "project:a AND").is_err());
2393 assert!(set_permission_expr(&conn, "user", &u.id, "read", "bogus:a").is_err());
2394 assert!(
2395 get_permission_expr(&conn, "user", &u.id, "read")
2396 .unwrap()
2397 .is_none(),
2398 "a rejected expression must not be stored"
2399 );
2400 }
2401
2402 #[test]
2403 fn blank_expression_clears_the_rule() {
2404 let conn = scratch_conn("permclear");
2405 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2406 set_permission_expr(&conn, "user", &u.id, "read", "tag:x").unwrap();
2407 set_permission_expr(&conn, "user", &u.id, "read", " ").unwrap();
2408 assert!(get_permission_expr(&conn, "user", &u.id, "read")
2409 .unwrap()
2410 .is_none());
2411 }
2412
2413 #[test]
2414 fn effective_read_includes_write_because_write_implies_read() {
2415 let conn = scratch_conn("permeff");
2416 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2417 set_permission_expr(&conn, "user", &u.id, "write", "project:Alpha").unwrap();
2418 let read = effective_permission_expr(&conn, &u.id, "read")
2419 .unwrap()
2420 .expect("write should imply read");
2421 assert!(read.to_string().contains("project:Alpha"));
2422 }
2423
2424 #[test]
2425 fn effective_expr_ands_class_with_individual() {
2426 let conn = scratch_conn("permand");
2427 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2428 let cls = create_user_class(&conn, "Contractor", "", false, false, false).unwrap();
2429 assign_user_class(&conn, &u.id, Some(&cls.id)).unwrap();
2430 set_permission_expr(&conn, "class", &cls.id, "read", "NOT category:secret").unwrap();
2431 set_permission_expr(&conn, "user", &u.id, "read", "vault:*").unwrap();
2432
2433 let expr = effective_permission_expr(&conn, &u.id, "read")
2434 .unwrap()
2435 .unwrap();
2436 let pn: HashMap<String, String> = HashMap::new();
2437 let secret =
2438 json!({ "provider": "S", "categories": ["secret"], "projectIds": ["Universal"] });
2439 let plain =
2440 json!({ "provider": "P", "categories": ["dev"], "projectIds": ["Universal"] });
2441 assert!(
2442 !crate::permex::eval(&expr, &crate::permex::EntryView::from_entry(&secret, &pn)),
2443 "the class exclusion must survive the individual grant"
2444 );
2445 assert!(crate::permex::eval(
2446 &expr,
2447 &crate::permex::EntryView::from_entry(&plain, &pn)
2448 ));
2449 }
2450
2451 #[test]
2452 fn a_user_with_no_rules_gets_nothing() {
2453 let conn = scratch_conn("permnone");
2454 let u = create_user(&conn, "u1", Some("pw"), false).unwrap();
2455 assert!(effective_permission_expr(&conn, &u.id, "read")
2456 .unwrap()
2457 .is_none());
2458 assert!(effective_permission_expr(&conn, &u.id, "write")
2459 .unwrap()
2460 .is_none());
2461 }
2462
2463 fn sample_vault() -> serde_json::Value {
2466 json!({
2467 "api_keys": [
2468 { "id": "1", "provider": "Mine", "categories": ["dev"], "projectIds": ["Universal", "p1"] },
2469 { "id": "2", "provider": "Theirs", "categories": ["ops"], "projectIds": ["Universal", "p2"] },
2470 ],
2471 "user_categories": ["dev", "ops", "secret/taxonomy"],
2472 "projects": [
2473 { "id": "Universal", "name": "Universal" },
2474 { "id": "p1", "name": "Alpha" },
2475 { "id": "p2", "name": "Beta" },
2476 ],
2477 })
2478 }
2479
2480 #[test]
2481 fn vault_scope_sees_everything_untouched() {
2482 let out = filter_vault_for_user(sample_vault(), Some(&ex("vault:*")));
2483 assert_eq!(out["api_keys"].as_array().unwrap().len(), 2);
2484 }
2485
2486 #[test]
2487 fn project_scope_hides_other_projects() {
2488 let out = filter_vault_for_user(sample_vault(), Some(&ex("project:Alpha")));
2489 let keys = out["api_keys"].as_array().unwrap();
2490 assert_eq!(keys.len(), 1);
2491 assert_eq!(keys[0]["provider"], "Mine");
2492 }
2493
2494 #[test]
2495 fn a_visible_bundle_member_does_not_reveal_an_unreadable_parent() {
2496 let mut full = sample_vault();
2497 full["api_keys"][0]["bundle_id"] = json!("private-bundle");
2498 full["api_keys"][0]["bundle_slot"] = json!("member");
2499 full["api_keys"][0]["bundle_order"] = json!(10);
2500 full["api_keys"].as_array_mut().unwrap().push(json!({
2501 "id": "private-bundle", "provider": "Private", "secretType": "bundle",
2502 "projectIds": ["Universal", "p2"]
2503 }));
2504
2505 let out = filter_vault_for_user(full, Some(&ex("project:Alpha")));
2506 let member = out["api_keys"]
2507 .as_array()
2508 .unwrap()
2509 .iter()
2510 .find(|entry| entry["id"] == "1")
2511 .unwrap();
2512 assert!(member.get("bundle_id").is_none());
2513 assert!(member.get("bundle_slot").is_none());
2514 assert!(member.get("bundle_order").is_none());
2515 assert!(!out["api_keys"]
2516 .as_array()
2517 .unwrap()
2518 .iter()
2519 .any(|e| e["id"] == "private-bundle"));
2520 }
2521
2522 #[test]
2523 fn filtering_does_not_leak_the_category_taxonomy() {
2524 let out = filter_vault_for_user(sample_vault(), Some(&ex("project:Alpha")));
2525 let cats: Vec<&str> = out["user_categories"]
2526 .as_array()
2527 .unwrap()
2528 .iter()
2529 .filter_map(|c| c.as_str())
2530 .collect();
2531 assert!(
2532 cats.contains(&"dev"),
2533 "categories on visible entries are kept"
2534 );
2535 assert!(
2536 !cats.contains(&"ops"),
2537 "categories only on hidden entries must not leak"
2538 );
2539 assert!(
2540 !cats.contains(&"secret/taxonomy"),
2541 "unused category names must not leak"
2542 );
2543 }
2544
2545 #[test]
2548 fn merge_rejects_submission_outside_write_scope() {
2549 let full = sample_vault();
2550 let submitted = json!({ "api_keys": [
2551 { "id": "2", "provider": "Theirs", "categories": ["ops"], "projectIds": ["Universal", "p2"] }
2552 ]});
2553 let err = merge_user_vault_write(
2554 full,
2555 submitted,
2556 Some(&ex("vault:*")),
2557 Some(&ex("project:Alpha")),
2558 )
2559 .expect_err("writing an out-of-scope entry must be refused");
2560 assert!(
2561 err.contains("Theirs"),
2562 "error should name the offending entry, got: {err}"
2563 );
2564 }
2565
2566 #[test]
2567 fn changing_bundle_membership_requires_write_access_to_both_entries() {
2568 let mut full = sample_vault();
2569 full["api_keys"].as_array_mut().unwrap().push(json!({
2570 "id": "bundle", "provider": "Restricted bundle", "secretType": "bundle",
2571 "projectIds": ["Universal", "p2"]
2572 }));
2573 let submitted = json!({ "api_keys": [
2574 { "id": "1", "provider": "Mine", "categories": ["dev"],
2575 "projectIds": ["Universal", "p1"], "bundle_id": "bundle" }
2576 ]});
2577 let err = merge_user_vault_write(
2578 full,
2579 submitted,
2580 Some(&ex("vault:*")),
2581 Some(&ex("project:Alpha")),
2582 )
2583 .expect_err("member write alone must not expose it through a restricted bundle");
2584 assert!(err.contains("bundle membership"), "unexpected error: {err}");
2585 }
2586
2587 #[test]
2588 fn unrelated_writes_preserve_membership_in_an_unreadable_bundle() {
2589 let mut full = sample_vault();
2590 full["api_keys"][0]["bundle_id"] = json!("private-bundle");
2591 full["api_keys"][0]["bundle_slot"] = json!("member");
2592 full["api_keys"][0]["bundle_order"] = json!(10);
2593 full["api_keys"].as_array_mut().unwrap().push(json!({
2594 "id": "private-bundle", "provider": "Private", "secretType": "bundle",
2595 "projectIds": ["Universal", "p2"]
2596 }));
2597 let read = ex("project:Alpha");
2598 let write = ex("project:Alpha");
2599 let mut submitted = filter_vault_for_user(full.clone(), Some(&read));
2600 submitted["api_keys"][0]["api_key"] = json!("updated");
2601
2602 let merged = merge_user_vault_write(full, submitted, Some(&read), Some(&write)).unwrap();
2603 let member = merged["api_keys"]
2604 .as_array()
2605 .unwrap()
2606 .iter()
2607 .find(|entry| entry["id"] == "1")
2608 .unwrap();
2609 assert_eq!(member["api_key"], "updated");
2610 assert_eq!(member["bundle_id"], "private-bundle");
2611 assert_eq!(member["bundle_slot"], "member");
2612 assert_eq!(member["bundle_order"], 10);
2613 }
2614
2615 #[test]
2616 fn merge_requires_some_write_permission() {
2617 let err = merge_user_vault_write(
2618 sample_vault(),
2619 json!({ "api_keys": [] }),
2620 Some(&ex("vault:*")),
2621 None,
2622 )
2623 .expect_err("read-only users must not be able to write at all");
2624 assert_eq!(err, "No write permissions");
2625 }
2626
2627 #[test]
2631 fn merge_applies_in_scope_edits_and_preserves_the_rest() {
2632 let submitted = json!({ "api_keys": [
2633 { "id": "1", "provider": "Mine", "api_key": "updated",
2634 "categories": ["dev"], "projectIds": ["Universal", "p1"] }
2635 ]});
2636 let out = merge_user_vault_write(
2637 sample_vault(),
2638 submitted,
2639 Some(&ex("vault:*")),
2640 Some(&ex("project:Alpha")),
2641 )
2642 .unwrap();
2643 let keys = out["api_keys"].as_array().unwrap();
2644 assert_eq!(keys.len(), 2, "the out-of-scope entry must survive");
2645 let mine = keys.iter().find(|e| e["id"] == "1").unwrap();
2646 assert_eq!(mine["api_key"], "updated");
2647 assert!(
2648 keys.iter().any(|e| e["id"] == "2"),
2649 "Theirs must be untouched"
2650 );
2651 }
2652
2653 #[test]
2654 fn merge_deletes_in_scope_entries_omitted_from_the_submission() {
2655 let out = merge_user_vault_write(
2656 sample_vault(),
2657 json!({ "api_keys": [] }),
2658 Some(&ex("vault:*")),
2659 Some(&ex("project:Alpha")),
2660 )
2661 .unwrap();
2662 let keys = out["api_keys"].as_array().unwrap();
2663 assert_eq!(keys.len(), 1, "the in-scope entry is deleted");
2664 assert_eq!(keys[0]["id"], "2", "the out-of-scope entry is not");
2665 }
2666
2667 fn alpha() -> (crate::permex::Expr, crate::permex::Expr) {
2676 (ex("project:Alpha"), ex("project:Alpha"))
2677 }
2678
2679 #[test]
2680 fn a_new_project_from_a_sub_user_is_persisted() {
2681 let (r, w) = alpha();
2682 let served = filter_vault_for_user(sample_vault(), Some(&r));
2683 let mut submitted = served.clone();
2684 submitted["projects"].as_array_mut().unwrap().push(json!({
2685 "id": "p9", "name": "Fresh", "project_type": "generic", "chunks": []
2686 }));
2687 let out = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w)).unwrap();
2688 let ids: Vec<&str> = out["projects"]
2689 .as_array()
2690 .unwrap()
2691 .iter()
2692 .filter_map(|p| p["id"].as_str())
2693 .collect();
2694 assert!(ids.contains(&"p9"), "the new project must survive: {ids:?}");
2695 assert!(ids.contains(&"p2"), "Beta, which they cannot see, must too");
2696 }
2697
2698 #[test]
2699 fn editing_a_project_they_fully_own_is_persisted() {
2700 let (r, w) = alpha();
2703 let mut submitted = filter_vault_for_user(sample_vault(), Some(&r));
2704 for p in submitted["projects"].as_array_mut().unwrap() {
2705 if p["id"] == "p1" {
2706 p["chunks"] = json!([{ "id": "c1", "name": "peer", "chunk_type": "wg_peer",
2707 "fields": [] }]);
2708 }
2709 }
2710 let out = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w)).unwrap();
2711 let alpha_out = out["projects"]
2712 .as_array()
2713 .unwrap()
2714 .iter()
2715 .find(|p| p["id"] == "p1")
2716 .unwrap();
2717 assert_eq!(
2718 alpha_out["chunks"][0]["name"], "peer",
2719 "the chunk the user added must be stored, not silently dropped"
2720 );
2721 }
2722
2723 #[test]
2724 fn a_project_they_do_not_fully_own_is_refused_not_silently_ignored() {
2725 let (r, w) = alpha();
2728 let mut submitted = filter_vault_for_user(sample_vault(), Some(&r));
2729 for p in submitted["projects"].as_array_mut().unwrap() {
2730 if p["id"] == "Universal" {
2731 p["name"] = json!("Hijacked");
2732 }
2733 }
2734 let err = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w))
2735 .expect_err("must refuse rather than accept-and-discard");
2736 assert!(err.contains("Universal"), "must name the refusal: {err}");
2737 }
2738
2739 #[test]
2740 fn a_project_the_user_cannot_see_survives_a_full_submission() {
2741 let (r, w) = alpha();
2745 let submitted = filter_vault_for_user(sample_vault(), Some(&r));
2746 assert!(
2747 !submitted["projects"]
2748 .as_array()
2749 .unwrap()
2750 .iter()
2751 .any(|p| p["id"] == "p2"),
2752 "fixture precondition: Beta is not served to this user"
2753 );
2754 let out = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w)).unwrap();
2755 assert!(
2756 out["projects"]
2757 .as_array()
2758 .unwrap()
2759 .iter()
2760 .any(|p| p["id"] == "p2"),
2761 "Beta must survive"
2762 );
2763 }
2764
2765 #[test]
2766 fn a_hidden_category_is_neither_deleted_nor_overwritable() {
2767 let (r, w) = alpha();
2768 let mut submitted = filter_vault_for_user(sample_vault(), Some(&r));
2769 submitted["user_categories"]
2770 .as_array_mut()
2771 .unwrap()
2772 .push(json!("newcat"));
2773 let out = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w)).unwrap();
2774 let cats: Vec<&str> = out["user_categories"]
2775 .as_array()
2776 .unwrap()
2777 .iter()
2778 .filter_map(|c| c.as_str())
2779 .collect();
2780 assert!(cats.contains(&"newcat"), "the added category persists");
2781 assert!(
2782 cats.contains(&"secret/taxonomy"),
2783 "a category never served must not be pruned away: {cats:?}"
2784 );
2785 assert!(cats.contains(&"ops"), "nor one belonging to hidden entries");
2786 }
2787
2788 #[test]
2789 fn omitting_the_collections_entirely_changes_nothing() {
2790 let (r, w) = alpha();
2794 let out = merge_user_vault_write(
2795 sample_vault(),
2796 json!({ "api_keys": [
2797 { "id": "1", "provider": "Mine", "categories": ["dev"],
2798 "projectIds": ["Universal", "p1"] }
2799 ]}),
2800 Some(&r),
2801 Some(&w),
2802 )
2803 .unwrap();
2804 assert_eq!(out["projects"].as_array().unwrap().len(), 3);
2805 assert_eq!(out["user_categories"].as_array().unwrap().len(), 3);
2806 }
2807
2808 #[test]
2809 fn a_sub_user_cannot_overwrite_a_project_by_guessing_its_id() {
2810 let (r, w) = alpha();
2814 let mut submitted = filter_vault_for_user(sample_vault(), Some(&r));
2815 submitted["projects"]
2816 .as_array_mut()
2817 .unwrap()
2818 .push(json!({ "id": "p2", "name": "Stolen" }));
2819 let out = merge_user_vault_write(sample_vault(), submitted, Some(&r), Some(&w)).unwrap();
2820 let betas: Vec<&serde_json::Value> = out["projects"]
2821 .as_array()
2822 .unwrap()
2823 .iter()
2824 .filter(|p| p["id"] == "p2")
2825 .collect();
2826 assert_eq!(betas.len(), 1, "must not duplicate it either");
2827 assert_eq!(betas[0]["name"], "Beta", "the real Beta is untouched");
2828 }
2829
2830 #[test]
2831 fn merge_cannot_alias_an_out_of_scope_entry_via_key_id() {
2832 let full = json!({ "api_keys": [
2835 { "provider": "AWS", "account_name": "acct", "key_id": "locked",
2836 "api_key": "secret", "categories": ["ops"], "projectIds": ["Universal", "p2"] },
2837 { "provider": "AWS", "account_name": "acct", "key_id": "mine",
2838 "api_key": "ok", "categories": ["dev"], "projectIds": ["Universal", "p1"] },
2839 ],
2840 "projects": [{ "id": "p1", "name": "Alpha" }, { "id": "p2", "name": "Beta" }]});
2841
2842 let submitted = json!({ "api_keys": [
2843 { "provider": "AWS", "account_name": "acct", "key_id": "mine",
2844 "api_key": "changed", "categories": ["dev"], "projectIds": ["Universal", "p1"] }
2845 ]});
2846 let out = merge_user_vault_write(
2847 full,
2848 submitted,
2849 Some(&ex("vault:*")),
2850 Some(&ex("project:Alpha")),
2851 )
2852 .unwrap();
2853 let keys = out["api_keys"].as_array().unwrap();
2854 let locked = keys.iter().find(|e| e["key_id"] == "locked").unwrap();
2855 assert_eq!(
2856 locked["api_key"], "secret",
2857 "the out-of-scope key must be untouched"
2858 );
2859 }
2860}
2861
2862#[cfg(test)]
2863mod strict_write_tests {
2864 use super::*;
2865 use crate::permex;
2866
2867 fn db() -> Connection {
2868 let c = Connection::open_in_memory().unwrap();
2869 crate::init_schema(&c).unwrap();
2873 init_users_schema(&c).unwrap();
2874 c
2875 }
2876
2877 #[test]
2879 fn require_all_turns_any_scope_into_every_scope() {
2880 let e = permex::parse("project:web OR project:api OR project:db").unwrap();
2881 let strict = permex::require_all(e);
2882 assert_eq!(
2883 strict.to_string(),
2884 "((project:web AND project:api) AND project:db)"
2885 );
2886 }
2887
2888 #[test]
2894 fn require_all_does_not_rewrite_nested_groups() {
2895 let e = permex::parse("(project:web OR project:api) AND env:prod").unwrap();
2896 let before = e.to_string();
2897 assert_eq!(permex::require_all(e).to_string(), before);
2898 }
2899
2900 #[test]
2906 fn strict_mode_narrows_writes_only() {
2907 let c = db();
2908 let uid = create_user(&c, "alice", Some("password-1234"), false)
2909 .unwrap()
2910 .id;
2911 set_permission_expr(&c, "user", &uid, "write", "project:web OR project:api").unwrap();
2912
2913 let lax = effective_permission_expr(&c, &uid, "write")
2914 .unwrap()
2915 .unwrap();
2916 assert_eq!(lax.to_string(), "(project:web OR project:api)");
2917
2918 set_strict_write(&c, "user", &uid, true).unwrap();
2919 let strict = effective_permission_expr(&c, &uid, "write")
2920 .unwrap()
2921 .unwrap();
2922 assert_eq!(strict.to_string(), "(project:web AND project:api)");
2923
2924 let read = effective_permission_expr(&c, &uid, "read")
2926 .unwrap()
2927 .unwrap();
2928 assert_eq!(read.to_string(), "(project:web OR project:api)");
2929 }
2930
2931 #[test]
2933 fn a_strict_class_makes_its_members_strict() {
2934 let c = db();
2935 let cid = create_user_class(&c, "Deployers", "", false, false, false)
2936 .unwrap()
2937 .id;
2938 let uid = create_user(&c, "bob", Some("password-1234"), false)
2939 .unwrap()
2940 .id;
2941 assign_user_class(&c, &uid, Some(&cid)).unwrap();
2942 set_permission_expr(&c, "user", &uid, "write", "project:web OR project:api").unwrap();
2943
2944 assert!(!strict_write_for(&c, &uid).unwrap());
2945 set_strict_write(&c, "class", &cid, true).unwrap();
2946 assert!(
2947 strict_write_for(&c, &uid).unwrap(),
2948 "a strict class must bind its members, or the class is not a boundary"
2949 );
2950 }
2951
2952 #[test]
2957 fn strict_is_off_by_default() {
2958 let c = db();
2959 let uid = create_user(&c, "carol", Some("password-1234"), false)
2960 .unwrap()
2961 .id;
2962 assert!(!strict_write_for(&c, &uid).unwrap());
2963 }
2964
2965 #[test]
2968 fn setting_strict_on_a_missing_subject_fails() {
2969 let c = db();
2970 assert!(set_strict_write(&c, "user", "no-such-id", true).is_err());
2971 assert!(set_strict_write(&c, "banana", "x", true).is_err());
2972 }
2973}
2974
2975#[cfg(test)]
2976mod totp_user_tests {
2977 use super::*;
2978 use crate::totp;
2979
2980 fn db() -> Connection {
2981 let c = Connection::open_in_memory().unwrap();
2982 crate::init_schema(&c).unwrap();
2983 init_users_schema(&c).unwrap();
2984 c
2985 }
2986
2987 fn a_user(c: &Connection) -> String {
2988 create_user(c, "alice", Some("hunter2hunter2"), false)
2989 .unwrap()
2990 .id
2991 }
2992
2993 #[test]
2994 fn enrollment_does_not_enable_the_factor() {
2995 let c = db();
2999 let id = a_user(&c);
3000 let st = totp_enroll(&c, &id, "UnENVerse").unwrap();
3001 assert!(st.enrolled && !st.enabled);
3002 assert!(st.secret.is_some() && st.uri.is_some());
3003 assert!(!totp_required(&c, &id).unwrap());
3004 let code = totp::totp_at(st.secret.as_ref().unwrap(), totp::now_unix()).unwrap();
3006 assert!(!verify_user_totp(&c, &id, &code).unwrap());
3007 }
3008
3009 #[test]
3010 fn confirming_enables_it_and_burns_the_confirming_code() {
3011 let c = db();
3012 let id = a_user(&c);
3013 let secret = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3014 let code = totp::totp_at(&secret, totp::now_unix()).unwrap();
3015
3016 assert!(totp_confirm(&c, &id, &code).unwrap());
3017 assert!(totp_required(&c, &id).unwrap());
3018 assert!(list_users(&c).unwrap().iter().any(|u| u.totp_enabled));
3019 assert!(!verify_user_totp(&c, &id, &code).unwrap());
3021 }
3022
3023 #[test]
3024 fn a_login_code_cannot_be_used_twice() {
3025 let c = db();
3028 let id = a_user(&c);
3029 let secret = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3030 let prev = totp::totp_at(&secret, totp::now_unix() - totp::STEP_SECS).unwrap();
3033 assert!(totp_confirm(&c, &id, &prev).unwrap());
3034
3035 let code = totp::totp_at(&secret, totp::now_unix()).unwrap();
3036 assert!(verify_user_totp(&c, &id, &code).unwrap());
3037 assert!(!verify_user_totp(&c, &id, &code).unwrap());
3038 }
3039
3040 #[test]
3041 fn confirm_obeys_the_anti_replay_mark_and_never_moves_it_backwards() {
3042 let c = db();
3048 let id = a_user(&c);
3049 let secret = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3050 let now = totp::now_unix();
3051
3052 let code = totp::totp_at(&secret, now).unwrap();
3053 assert!(totp_confirm(&c, &id, &code).unwrap());
3054 assert!(
3055 !totp_confirm(&c, &id, &code).unwrap(),
3056 "the confirming code must not be accepted twice"
3057 );
3058
3059 let mark = || -> i64 {
3060 c.query_row(
3061 "SELECT totp_last_step FROM users WHERE id = ?1",
3062 rusqlite::params![id],
3063 |r| r.get(0),
3064 )
3065 .unwrap()
3066 };
3067 let after_confirm = mark();
3068 let older = totp::totp_at(&secret, now - totp::STEP_SECS).unwrap();
3071 assert!(!totp_confirm(&c, &id, &older).unwrap());
3072 assert_eq!(mark(), after_confirm, "the mark moved backwards");
3073 }
3074
3075 #[test]
3076 fn an_enabled_factor_with_no_secret_fails_closed() {
3077 let c = db();
3081 let id = a_user(&c);
3082 c.execute(
3083 "UPDATE users SET totp_enabled = 1, totp_secret = NULL WHERE id = ?1",
3084 rusqlite::params![id],
3085 )
3086 .unwrap();
3087 assert!(!verify_user_totp(&c, &id, "123456").unwrap());
3088 assert!(!verify_user_totp(&c, &id, "").unwrap());
3089 }
3090
3091 #[test]
3092 fn status_never_hands_the_secret_back() {
3093 let c = db();
3095 let id = a_user(&c);
3096 totp_enroll(&c, &id, "UnENVerse").unwrap();
3097 let st = totp_status(&c, &id).unwrap();
3098 assert!(st.enrolled && !st.enabled);
3099 assert!(st.secret.is_none() && st.uri.is_none());
3100 }
3101
3102 #[test]
3103 fn disabling_destroys_the_secret_rather_than_only_the_flag() {
3104 let c = db();
3105 let id = a_user(&c);
3106 let secret = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3107 let code = totp::totp_at(&secret, totp::now_unix()).unwrap();
3108 totp_confirm(&c, &id, &code).unwrap();
3109
3110 totp_disable(&c, &id).unwrap();
3111 let st = totp_status(&c, &id).unwrap();
3112 assert!(!st.enrolled, "a leftover secret could be silently re-armed");
3113 assert!(!st.enabled);
3114 }
3115
3116 #[test]
3117 fn re_enrolling_switches_the_factor_back_off() {
3118 let c = db();
3121 let id = a_user(&c);
3122 let first = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3123 let code = totp::totp_at(&first, totp::now_unix()).unwrap();
3124 totp_confirm(&c, &id, &code).unwrap();
3125 assert!(totp_required(&c, &id).unwrap());
3126
3127 let second = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3128 assert_ne!(first, second);
3129 assert!(!totp_required(&c, &id).unwrap());
3130 }
3131
3132 #[test]
3133 fn the_owner_is_refused_a_second_factor() {
3134 let c = db();
3137 let owner = ensure_owner_user(&c).unwrap();
3138 assert!(totp_enroll(&c, &owner, "UnENVerse").is_err());
3139 assert!(totp_confirm(&c, &owner, "123456").is_err());
3140 }
3141
3142 #[test]
3143 fn confirm_without_enrollment_is_an_error_not_a_silent_false() {
3144 let c = db();
3145 let id = a_user(&c);
3146 assert!(totp_confirm(&c, &id, "123456").is_err());
3147 }
3148
3149 #[test]
3150 fn the_schema_migration_runs_on_a_database_that_predates_it() {
3151 let c = db();
3155 init_users_schema(&c).unwrap();
3156 init_users_schema(&c).unwrap();
3157 let id = a_user(&c);
3158 assert!(!totp_status(&c, &id).unwrap().enrolled);
3159 }
3160}