Skip to main content

vault_core/
users.rs

1//! User management, token management, and RBAC for UnENVerse.
2//!
3//! Users are stored in the SQLCipher-encrypted vault database — they can only
4//! be read or created when the owner has unlocked the vault (i.e. the vault key
5//! is in memory).  The owner is the only party who can manage users.
6//!
7//! # Auth modes
8//! - Username + password: stored as `SHA-256(salt || password)` with a 16-byte random salt.
9//! - Token: 32 random bytes returned as a 64-char hex string once; stored as `SHA-256(token)`.
10//!
11//! # Permission model
12//! Each permission has:
13//! - `scope_type`:  `"vault"` | `"project"` | `"category"`
14//! - `scope_value`: `"*"`, or a glob like `"wg0-*"` / `"Cloud/AWS"`
15//! - `permission`:  `"read"` | `"write"` (write implies read)
16//!
17//! Glob rules: `*` matches any sequence of characters (including empty); `?` matches one char.
18
19use crate::iso_now;
20use rand::RngCore;
21use rusqlite::{Connection, OptionalExtension};
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24use std::collections::{HashMap, HashSet};
25
26/// One `(scope_type, scope_value)` pair from the `user_permissions` table.
27///
28/// Named because it appears three deep inside `PermissionsBySubject` below, and
29/// clippy's `type_complexity` is right that the nested form is unreadable.
30type Scope = (String, String);
31
32/// `subject id -> (read scopes, write scopes)`.
33///
34/// A write scope also grants read, so the read vector is the superset; see
35/// `load_all_permissions`, which pushes into both.
36type PermissionsBySubject = HashMap<String, (Vec<Scope>, Vec<Scope>)>;
37
38/// The `users` columns every lookup selects, in `SELECT` order:
39/// `id, username, password_hash, is_owner, created_at, last_seen_at, class_id,
40/// strict_write, totp_enabled`.
41///
42/// `password_hash` and `last_seen_at` are nullable — a user created by an
43/// administrator has no hash until first login, and has never been seen.
44type 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// ── Public types ──────────────────────────────────────────────────────────────
57
58/// A named user class (role template) with capabilities and permissions.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct UserClass {
61    pub id: String,
62    pub name: String,
63    pub description: String,
64    /// Can create/delete users and assign classes.
65    pub cap_manage_users: bool,
66    /// Can create/edit/delete user classes (admin-level).
67    pub cap_manage_classes: bool,
68    /// Can delete projects.
69    pub cap_delete_projects: bool,
70    pub strict_write: bool,
71    pub created_at: String,
72}
73
74/// A single permission row scoped to a user class (no user_id — applies to all class members).
75#[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/// A vault user (password hash is never exposed via this struct).
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct UserRecord {
86    pub id: String,
87    pub username: String,
88    /// True when the user has a password set (can use username+password auth).
89    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    /// True when the user has a *confirmed* second factor. Enrollment alone does
96    /// not set it — see [`totp_enroll`] for why the two are separate.
97    pub totp_enabled: bool,
98}
99
100/// A stored API token descriptor.  The actual token is returned only on creation.
101#[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/// A single RBAC permission row.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct PermissionRecord {
113    pub user_id: String,
114    /// `"vault"` | `"project"` | `"category"`
115    pub scope_type: String,
116    /// Glob pattern: `"*"`, `"wg0-*"`, `"Cloud/AWS"`, etc.
117    pub scope_value: String,
118    /// `"read"` | `"write"` (write implies read)
119    pub permission: String,
120}
121
122// ── Schema ────────────────────────────────────────────────────────────────────
123
124/// Creates all user-related tables (idempotent) and seeds default classes.
125pub 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    // Idempotent migrations
177    conn.execute("ALTER TABLE users ADD COLUMN class_id TEXT", [])
178        .ok();
179    // Strict write scoping. Defaults to 0 — existing users keep the behaviour
180    // they had, because a migration that silently tightened permissions would
181    // break running deployments in a way nobody could attribute to an upgrade.
182    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    // TOTP, three columns rather than one:
193    //   totp_secret     — base32, present from enrollment onwards
194    //   totp_enabled    — set only once a code has been confirmed
195    //   totp_last_step  — the anti-replay high-water mark
196    // Splitting secret from enabled is what makes enrollment two-phase: a secret
197    // that exists but is not enabled locks nobody out if the authenticator never
198    // actually took the QR-less manual entry.
199    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    // Seed default classes if none exist
210    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    // Must run *after* seeding: on a fresh database the seeded class permission
218    // rows are themselves what gets compiled into expressions. Run before, and
219    // the built-in Admin/Moderator/Viewer classes would end up with no rules at
220    // all — silently denying everything.
221    migrate_rows_to_expressions(conn)?;
222    Ok(())
223}
224
225// ── Permission expressions ────────────────────────────────────────────────────
226
227/// One-time compilation of the legacy `(scope_type, scope_value, permission)`
228/// rows into equivalent expressions.
229///
230/// Legacy rows always meant "any of these matches", so they compile to an OR
231/// chain — which reproduces the old read behaviour exactly. Guarded by a marker
232/// in `vault_meta` rather than by "is the table empty", so deliberately clearing
233/// every expression does not resurrect the old rules on the next start.
234fn 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        // subject -> (read scopes, write scopes). Write also grants read.
260        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
294/// Reads a stored expression for a subject, if any.
295pub 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
311/// Stores an expression, or clears it when `expression` is empty/blank.
312///
313/// Validates by parsing first: an unparseable expression denies everything at
314/// evaluation time, so letting one be saved would silently lock a user out.
315pub 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
347/// Whether writes for this user must satisfy **every** scope rather than any.
348///
349/// True when the user *or* their class has it set. The OR is deliberate and is
350/// the fail-closed direction: a class that exists to constrain a group must not
351/// be looser than the group, and an individual asked to be strict must not be
352/// relaxed by their class.
353pub 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
379/// Turn strict write scoping on or off for a user or a class.
380pub 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
403/// Resolves what a user may actually do, combining their class and individual rules.
404///
405/// - Class and individual are **AND**ed, so a class exclusion cannot be undone
406///   by an individual grant.
407/// - `None` means no grant at all — callers must deny. Absent expressions are
408///   deliberately not treated as "no restriction": under AND that would give a
409///   user with no permissions whatsoever full access.
410/// - Write implies read, so the effective read rule is `read OR write`.
411/// - Under strict write scoping the write rule is narrowed further — see
412///   [`strict_write_for`] and [`crate::permex::require_all`].
413pub 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    // Stored text is always re-parsed rather than trusted: a row edited outside
429    // the app must fail closed.
430    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        // Strict mode narrows writes only. Reads keep ANY-match semantics
446        // deliberately: a user who cannot see an entry cannot review what they
447        // are about to change, and tightening reads here would make the strict
448        // user's own vault look empty.
449        "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        // Note this reads the *unstrictified* write rule, so turning strict mode
457        // on never removes visibility — only the ability to change.
458        "read" => Ok(crate::permex::any_of(raw("read")?, raw("write")?)),
459        other => Err(format!("unknown permission '{other}'")),
460    }
461}
462
463/// Seeds the three built-in user classes: Admin, Moderator, Viewer.
464fn 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)], // (scope_type, scope_value, permission)
475    }
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
523// ── Internal helpers ──────────────────────────────────────────────────────────
524
525/// Kept as a local name because this module reaches for it constantly; the one
526/// implementation is [`crate::new_uuid`], which the desktop app's TOTP import
527/// also calls. A second generator would be a second thing to get the version
528/// and variant bits wrong in.
529fn new_uuid() -> String {
530    crate::new_uuid()
531}
532
533/// Hashes `password` with Argon2id (m=32768, t=2, p=1) and returns a
534/// self-describing PHC string `$argon2id$v=19$...` suitable for long-term storage.
535///
536/// Uses lower cost params than the vault KDF to keep interactive login fast
537/// while still providing >200ms hash time on modern hardware.
538fn 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
551/// Verifies `password` against `stored`.
552///
553/// Supports two on-disk formats:
554/// - **PHC** (`$argon2id$…`) — new default since Phase 5.1.
555/// - **Legacy SHA-256** (`<salt_hex>:<hash_hex>`) — written by Phase 5.0 and earlier.
556///
557/// Returns `true` only when the password matches.  Callers should re-hash with
558/// `hash_password` after a successful legacy verification to upgrade the stored hash.
559fn 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        // Legacy: "<salt_hex>:<sha256_hex>" — upgrade on next login
571        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        // Constant-time comparison — avoids timing side-channel on legacy hashes.
586        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
595/// Runs one Argon2 verification against a throwaway hash and discards the result.
596///
597/// An unknown username used to return in microseconds while a known one spent
598/// ~100 ms in Argon2, so the response time alone said which usernames exist. The
599/// dummy is made with [`hash_password`], so its cost parameters always match what
600/// a real user pays.
601fn 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// Test hook: how many times the dummy verification ran on this thread (each
610// test has its own thread, so parallel tests cannot disturb the count).
611#[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
631// ── User CRUD ─────────────────────────────────────────────────────────────────
632
633/// Creates a new user.  Pass `password = None` for token-only auth.
634pub 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
662/// Renames a user (owner or non-owner).  Fails if `new_username` is already taken.
663pub 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
672/// Updates (or clears) a user's password.
673pub 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
686/// Verifies username + password.  Returns `None` on invalid credentials.
687pub 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    // A user with no password (token-only, or the owner row) simply cannot
730    // authenticate this way — that is an ordinary credential failure, not a
731    // server error.
732    //
733    // This previously returned Err, which the server turned into a 500 while a
734    // nonexistent username returned 401, and only the 401 path incremented the
735    // rate limiter. The 500-vs-401 difference was an unthrottled username
736    // enumeration oracle.
737    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    // Transparent upgrade: rehash with Argon2id when the stored hash is legacy SHA-256.
745    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
766/// Verifies a raw 64-char hex token.  Returns `None` if invalid or expired.
767pub 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
822/// Lists all users, owners first, then by creation date.
823pub 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// ── TOTP (second factor for sub-users) ────────────────────────────────────────
852
853/// What the UI and CLI need to draw a user's second-factor state.
854#[derive(Debug, Clone, Serialize, Deserialize)]
855pub struct TotpStatus {
856    /// A secret exists. Enrollment has started but may never have been confirmed.
857    pub enrolled: bool,
858    /// A code has been confirmed, and login now requires one.
859    pub enabled: bool,
860    /// Present **only** in the response to [`totp_enroll`]. Never returned by
861    /// [`totp_status`]: the secret is shown once, at the moment the user is
862    /// putting it into their authenticator, and a status endpoint that handed it
863    /// back would make every later read of the user list a way to clone the
864    /// factor.
865    pub secret: Option<String>,
866    /// The `otpauth://` URI for the same secret, on the same terms.
867    pub uri: Option<String>,
868}
869
870/// Refuses anything to do with TOTP for the vault owner.
871///
872/// The owner authenticates by deriving the SQLCipher key from the master
873/// password; there is no stored hash and no login form in that path, so a second
874/// factor here would be a control that gates nothing while appearing to gate
875/// everything. Enforced in `vault-core` rather than at each caller, because a
876/// check that lives in the server is one the CLI and the desktop app each have
877/// to remember separately.
878fn 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
898/// Phase one of enrollment: mints a secret and returns it with its URI.
899///
900/// The factor is **not** enabled by this call. Enabling on enrollment locks out
901/// any user whose authenticator did not actually take the secret — which, with
902/// manual base32 entry and no QR code, is a routine outcome rather than an edge
903/// case. [`totp_confirm`] is what turns it on, and it requires a working code.
904///
905/// Re-enrolling an already-enabled user replaces the secret and switches the
906/// factor back off, so a half-finished re-enrollment cannot leave the account
907/// requiring a code nobody can generate.
908pub 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
934/// Phase two: enables the factor once the user proves their authenticator works.
935///
936/// The confirming code's step is recorded, so the very code used to enable the
937/// factor cannot then be replayed to log in with it.
938///
939/// **`confirm` obeys the same anti-replay mark as `verify`, and never moves it
940/// backwards.** Passing `None` here — which the first version did — makes this a
941/// second oracle where one code works twice, and worse: re-confirming with a
942/// code from an *earlier* step wrote that lower step back, re-opening every step
943/// in between for replay on the login path. Enrollment is what clears the mark,
944/// and it does so deliberately (see [`totp_enroll`]).
945pub 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
973/// Removes the factor entirely, secret included.
974///
975/// Clearing the secret rather than only the flag matters: leaving it behind
976/// means a later `totp_enable` could silently re-arm a secret the user believes
977/// they destroyed, on an authenticator entry they may have deleted.
978pub 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
988/// Reports enrollment and enabled state. Never returns the secret — see
989/// [`TotpStatus::secret`].
990pub 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
1006/// Whether login for this user must present a code.
1007pub 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
1020/// Verifies a login code and advances the anti-replay high-water mark.
1021///
1022/// Fails **closed**: a NULL secret on an enabled account returns `false` rather
1023/// than `true`. That exact inversion is in this project's bug history — the
1024/// Phase 5.1 implementation returned `Ok(true)` for a missing secret, which made
1025/// "enabled but unconfigured" the same as "authenticated".
1026///
1027/// The step is written back before the caller is told the code was good, so a
1028/// concurrent second attempt with the same code loses the race rather than
1029/// winning it.
1030pub 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
1060/// Deletes a user plus all their tokens and permissions.
1061/// Returns `Err` if `user_id` belongs to the owner account.
1062pub 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
1092// ── User class CRUD ───────────────────────────────────────────────────────────
1093
1094pub 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    // Unassign users first
1163    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
1236/// Returns the capabilities of the user's class (None values mean no class = no extra capabilities).
1237pub 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
1264// ── Authority hierarchy (Discord-style: owner > admin > moderator > user) ──────
1265
1266/// Derives an authority tier from capability flags. Higher acts on strictly lower.
1267/// - 3 = owner (master-password session; never a stored user row)
1268/// - 2 = admin     (`cap_manage_classes`)
1269/// - 1 = moderator (`cap_manage_users` only)
1270/// - 0 = user      (no management capabilities)
1271pub 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
1283/// Authority tier implied by a class's stored capabilities. Returns 0 if unknown.
1284pub 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
1298/// Returns the owning user id of a token, or `None` if the token id is unknown.
1299pub 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
1309/// Authority tier of a stored user (by their class capabilities, or the `is_owner` flag).
1310/// Returns 0 for an unknown user id.
1311pub 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
1327/// Ensures the vault has exactly one owner row and returns its id.
1328///
1329/// The owner is whoever can derive the SQLCipher key from the master password.
1330/// Before this existed the server represented them with the magic string
1331/// `"owner"`, which meant they appeared in no user list, carried no class, and
1332/// could not be named in an audit row.
1333///
1334/// The row is deliberately created with **`password_hash = NULL`**. Storing a
1335/// hash of the master password here would put an offline oracle for the vault
1336/// key back into the database — the exact hole removed from the desktop
1337/// `unlock_vault`. Proof of ownership stays "your password opened the
1338/// database"; `verify_user_password` refuses a NULL hash, so this row can never
1339/// be logged into via `/api/auth`.
1340pub 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    // `username` is UNIQUE, so fall back if a normal user already took "owner".
1352    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
1375/// Outcome of [`seed_default_admin`].
1376pub enum AdminSeed {
1377    /// An `admin` user already existed — nothing was created.
1378    Exists,
1379    /// A new `admin` user (assigned the built-in `cls-admin` class) was created.
1380    /// `generated_password` is `Some` only when no env password was supplied and a
1381    /// random one was generated — the caller must surface it once.
1382    Created { generated_password: Option<String> },
1383}
1384
1385/// Idempotently seeds a default `admin` user assigned to the built-in `cls-admin`
1386/// class. Never hardcodes a credential: uses `env_password` when present, otherwise
1387/// generates a 128-bit random password the caller is responsible for displaying once.
1388pub 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
1419// ── Token management ──────────────────────────────────────────────────────────
1420
1421/// Creates a token for `user_id`.
1422/// Returns `(token_id, plaintext_token)` — the plaintext is shown **once**.
1423pub 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
1450/// Revokes a token by its UUID.
1451pub 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
1460/// Lists all tokens for a user (no hashes).
1461pub 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
1485// ── Permission management ─────────────────────────────────────────────────────
1486
1487/// Returns all permissions for a user.
1488pub 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
1515/// Atomically replaces all permissions for a user.
1516pub 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
1537// ── Vault filtering ───────────────────────────────────────────────────────────
1538
1539/// Standard wildcard matching: `*` = any sequence, `?` = one char.
1540pub 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
1583/// Returns a filtered copy of `vault` containing only entries readable under `read`.
1584/// Also trims the `projects` and `user_categories` lists to what the visible
1585/// entries actually reference, so the taxonomy itself does not leak.
1586///
1587/// `read = None` means no grant at all and yields an empty vault.
1588pub 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    // A visible member must not disclose that its parent bundle exists when the
1615    // user cannot read that bundle. Present it as standalone; writes below
1616    // restore this association unless the user has explicit bundle access and
1617    // requests a membership change.
1618    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    // Don't leak the full category taxonomy: expose only category tags that a
1669    // visible entry actually carries (plus their slash-hierarchy parents so the
1670    // sidebar tree still renders).
1671    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            // "Cloud/AWS/Prod" -> ["Cloud", "Cloud/AWS", "Cloud/AWS/Prod"]
1682            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
1702/// Is every entry currently referencing `pid` writable by this user?
1703///
1704/// The same ALL-scope rule entry writes already use: a container is writable
1705/// only when the user could write everything it holds. Anything weaker lets a
1706/// user with one entry in a shared project rename or delete the project for
1707/// everybody.
1708///
1709/// A project nothing references is vacuously writable, which is correct — it is
1710/// also invisible, so the caller only reaches this for one the user was served.
1711fn 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
1726/// The category equivalent of [`project_write_allowed`].
1727fn 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
1742/// Merges one collection of named, string-keyed objects (`projects`) or plain
1743/// strings (`user_categories`) from a sub-user's submission.
1744///
1745/// The submission is a *filtered* view, so "absent" is ambiguous: it means
1746/// either "deleted" or "you were never shown it". `served` — recomputed here
1747/// from the read expression, which is exactly what the client was handed —
1748/// resolves it. Anything outside `served` is preserved untouched; anything
1749/// inside it is honoured, subject to `allowed`.
1750fn 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            // The client never saw it, so it cannot have meant to change it.
1771            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                // omitted from a view that contained it → deleted
1787            }
1788        }
1789    }
1790
1791    // Genuinely new ones. A submitted key that exists in `full` but was not
1792    // served is deliberately *not* treated as new: it would overwrite something
1793    // the user was never allowed to see.
1794    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
1805/// Merges a user's submitted vault data into the full vault, respecting write permissions.
1806///
1807/// - Entries the user submitted within their write scope → applied (add / update).
1808/// - Entries in the user's write scope that are absent from the submission → deleted.
1809/// - Entries outside the user's write scope → unchanged from `full_vault`.
1810/// - `projects` and `user_categories` are merged the same way, against what the
1811///   read expression would have served, with container writability defined as
1812///   "every entry inside it is writable".
1813///
1814/// Returns `Err` if the user's submission contains an entry outside their write
1815/// scope, or changes a project or category they may not change.
1816///
1817/// **`projects` and `user_categories` used to be dropped silently.** The merge
1818/// rebuilt `api_keys` only and took both collections from `full_vault`, and the
1819/// server answered `204 No Content`. A sub-user creating a project, renaming a
1820/// category, editing a WireGuard peer or adding a chunk got a success toast and
1821/// a UI that showed the change — because the frontend had already applied it to
1822/// its own copy — while the server persisted nothing. It surfaced on the next
1823/// reload, attributable to nothing.
1824pub 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    // Identity comes from the shared `crate::entry_ck` so this merge and
1869    // `save_vault` can never disagree about what "the same entry" means. A
1870    // mismatch here would let a write-scoped entry alias one in an out-of-scope
1871    // project (overwrite/delete bypass).
1872    use crate::entry_ck;
1873
1874    // Validate: every submitted entry must satisfy the write expression.
1875    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                // A stale membership can be removed without permission on a
1911                // bundle that no longer exists.
1912                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    // Keep current entries; apply user's changes to writable ones
1938    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: user deleted it — omit
1961        } else {
1962            result.push(entry.clone());
1963        }
1964    }
1965
1966    // Append genuinely new entries from user's submission
1967    for entry in user_keys {
1968        if !full_cks.contains(&entry_ck(entry)) {
1969            result.push(entry.clone());
1970        }
1971    }
1972
1973    // ── projects and user_categories ──────────────────────────────────────────
1974    //
1975    // Recompute what a GET would have handed this user. That is the only way to
1976    // read the submission correctly: it is a filtered document, so an absent
1977    // project means "deleted" when the user could see it and "never shown"
1978    // otherwise, and the two must not be confused.
1979    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    // An *absent* key is not an empty one. A caller that PUTs only `api_keys` —
1996    // which the server's payload validation permits, and which any hand-rolled
1997    // agent client will do — means "I am not touching the taxonomy", not "delete
1998    // every project I can see". Only a key that is present is merged.
1999    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// ── Tests ─────────────────────────────────────────────────────────────────────
2049
2050#[cfg(test)]
2051mod tests {
2052    use super::*;
2053    use serde_json::json;
2054
2055    // ── Glob matching ─────────────────────────────────────────────────────────
2056
2057    #[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        // "Cloud" must not grant access to "CloudSecrets".
2074        assert!(!glob_matches("Cloud", "CloudSecrets"));
2075        assert!(glob_matches("Cloud*", "CloudSecrets"));
2076    }
2077
2078    // ── Password hashing ──────────────────────────────────────────────────────
2079
2080    #[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        // Phase 5.0 format: "<salt_hex>:<sha256(salt||password)_hex>"
2103        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    // ── Authority hierarchy ───────────────────────────────────────────────────
2121
2122    #[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    // ── Write scoping ─────────────────────────────────────────────────────────
2135    //
2136    // Predicate-level semantics (wildcards, Universal, globs) are covered by the
2137    // permex tests. What matters here is that the merge honours the expression.
2138
2139    fn ex(src: &str) -> crate::permex::Expr {
2140        crate::permex::parse(src).unwrap()
2141    }
2142
2143    // ── Owner row ─────────────────────────────────────────────────────────────
2144
2145    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        // Storing a hash of the master password here would be an offline oracle
2177        // for the vault key, so the row must never have one.
2178        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        // 500-vs-401 used to leak which usernames existed, unthrottled, because
2195        // only the Ok(None) path incremented the rate limiter.
2196        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    /// Unknown and password-less usernames cost one Argon2 run, like a wrong
2205    /// password for a real user; before, they returned at once and the response
2206    /// time revealed which usernames exist.
2207    #[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        // Control: a real user is verified against its own hash, not the dummy.
2233        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    /// Measurement, not a gate (wall-clock asserts are flaky): mean time for an
2247    /// unknown versus a known username over 50 attempts each.
2248    /// `cargo test -p vault-core --release unknown_username_timing -- --ignored --nocapture`
2249    #[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    // ── Permission expressions ────────────────────────────────────────────────
2296
2297    #[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        // Re-run the migration as a fresh database would.
2321        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        // Already migrated at schema init, so the legacy rows must not clobber
2362        // an expression an admin has since written.
2363        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        // The seeded class rows must be compiled, or Admin/Viewer would silently
2374        // grant nothing at all.
2375        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    // ── Vault filtering ───────────────────────────────────────────────────────
2464
2465    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    // ── Write merge ───────────────────────────────────────────────────────────
2546
2547    #[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    /// Write coverage for entry "Mine" — under ANY-match the project grant alone
2628    /// is sufficient, since the entry belongs to project Alpha.
2629
2630    #[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    // ── projects and categories (regression: they used to vanish) ─────────────
2668    //
2669    // `merge_user_vault_write` rebuilt `api_keys` only and took `projects` and
2670    // `user_categories` straight from `full_vault`. The server then answered
2671    // 204. A sub-user creating a project or editing a chunk saw the change (the
2672    // frontend applies it to its own copy first) and the server kept nothing.
2673
2674    /// A read+write grant over Alpha, which is what a project-scoped sub-user has.
2675    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        // Alpha is referenced only by entry "Mine", which this user can write,
2701        // so the whole project is theirs to change — chunks included.
2702        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        // Universal is referenced by both entries, one of which is out of scope.
2726        // Under the same ALL-scope rule entry writes use, it is not theirs.
2727        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        // The submission is a filtered document. Beta's absence from it means
2742        // "never shown", not "deleted" — confusing the two deletes other
2743        // people's projects on every sub-user save.
2744        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        // A hand-rolled agent client PUTs only `api_keys`. Absent must mean
2791        // "unchanged", never "empty" — otherwise one such call wipes the
2792        // taxonomy for everybody.
2793        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        // p2 exists but was never served. Submitting it as if it were new must
2811        // not clobber it — that would be a read-scope bypass through the write
2812        // path.
2813        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        // Identity includes key_id. If it did not, a writable entry could be
2833        // crafted to collide with an out-of-scope one and overwrite it.
2834        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        // The user schema's expression migration writes a marker into
2870        // `vault_meta`, which the vault schema owns — so the vault schema comes
2871        // first here, exactly as it does in `unlock_vault`.
2872        crate::init_schema(&c).unwrap();
2873        init_users_schema(&c).unwrap();
2874        c
2875    }
2876
2877    /// The transform is the feature: an OR chain becomes an AND chain.
2878    #[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    /// Explicit grouping an author wrote is left alone.
2889    ///
2890    /// `(a OR b) AND c` is a rule someone stated precisely. Rewriting its inner
2891    /// alternation would change a decision that was already made, and strictness
2892    /// is about the implicit OR that scope-joining introduced.
2893    #[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    /// Strict mode narrows writes and leaves reads alone.
2901    ///
2902    /// Regression test for the tempting mistake: strictifying reads too. A user
2903    /// who cannot see an entry cannot review the change they are making, and
2904    /// their vault would appear empty the moment the flag went on.
2905    #[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        // Read still sees either, via "write implies read".
2925        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    /// A class can impose strictness its members cannot shed.
2932    #[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    /// Existing users are untouched by the migration.
2953    ///
2954    /// A release that silently tightened permissions would break running
2955    /// deployments in a way nobody could attribute to the upgrade.
2956    #[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    /// Setting the flag on something that does not exist is an error, not a
2966    /// silent no-op that leaves an operator believing they hardened an account.
2967    #[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        // Enabling on enrollment locks out anyone whose authenticator did not
2996        // actually take the manually-typed secret — with no QR code, that is a
2997        // routine outcome, not an edge case.
2998        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        // And a login code is refused while it is unconfirmed.
3005        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        // The code that enabled the factor must not then log the user in.
3020        assert!(!verify_user_totp(&c, &id, &code).unwrap());
3021    }
3022
3023    #[test]
3024    fn a_login_code_cannot_be_used_twice() {
3025        // The replay window without this is ninety seconds wide, which for a
3026        // second factor is the attack it exists to stop.
3027        let c = db();
3028        let id = a_user(&c);
3029        let secret = totp_enroll(&c, &id, "UnENVerse").unwrap().secret.unwrap();
3030        // Confirm with a code from the previous step so the current one is still
3031        // usable for the login this test is about.
3032        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        // Found by an end-to-end run, not by the unit tests: `confirm` passed
3043        // `None` for the last accepted step, so the confirming code worked a
3044        // second time — and re-confirming with a code from an *earlier* step
3045        // wrote that lower step back, re-opening every step in between for
3046        // replay on the login path.
3047        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        // An older code, still inside the skew window, must not be accepted and
3069        // must not drag the high-water mark back with it.
3070        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        // This precise inversion is in the bug history: the Phase 5.1 code
3078        // returned Ok(true) for a NULL secret, making "enabled but unconfigured"
3079        // indistinguishable from "authenticated".
3080        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        // Otherwise every read of the user list is a way to clone the factor.
3094        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        // A half-finished re-enrollment must not leave the account demanding a
3119        // code that only the abandoned secret can produce.
3120        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        // The owner authenticates by deriving the SQLCipher key. A factor there
3135        // gates nothing while looking as though it gates everything.
3136        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        // Idempotent ALTER TABLE, run twice, on a users table created before the
3152        // columns existed. A migration that throws on second run takes the whole
3153        // unlock with it.
3154        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}