feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -28,6 +28,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("021_rebuild_items_fts", MIGRATION_021),
|
||||
("022_people_fts", MIGRATION_022),
|
||||
("023_downloads_expiry", MIGRATION_023),
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -819,3 +820,250 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||
ON downloads(download_source, expires_at);
|
||||
"#;
|
||||
|
||||
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
|
||||
///
|
||||
/// Three tables, one purpose each:
|
||||
///
|
||||
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
|
||||
/// wrapping the access token, because a wrapped token would leave a locked
|
||||
/// profile unable to resume its own downloads or drain its own sync queue
|
||||
/// until someone typed the code. See DR-268 for why that trade was taken.
|
||||
/// - `user_item_visibility` records what the server has actually shown to each
|
||||
/// user. It is written as a byproduct of the cache write path, never rebuilt,
|
||||
/// so it cannot disagree with what the server returned.
|
||||
/// - `download_grants` separates the bytes from the claim on them, so one file
|
||||
/// can serve several profiles and is unlinked only when the last claim goes.
|
||||
///
|
||||
/// The backfill is not optional. Every existing cache row and download predates
|
||||
/// the concept of a user; without it an upgrading install's library goes blank.
|
||||
/// It grants the *active* user only — other pre-existing rows re-populate from
|
||||
/// the server on next browse, which is strictly safer than handing every
|
||||
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
|
||||
/// install whose single user somehow has `is_active = 0`.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
|
||||
const MIGRATION_024: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
|
||||
|
||||
-- Backfill: the active user has seen everything already cached on this device.
|
||||
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
|
||||
SELECT u.id, i.id
|
||||
FROM users u CROSS JOIN items i
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
|
||||
SELECT u.id, l.id
|
||||
FROM users u CROSS JOIN libraries l
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
-- Downloads already record who asked for them, so every existing row becomes
|
||||
-- exactly one grant held by its original requester.
|
||||
INSERT OR IGNORE INTO download_grants (user_id, download_id)
|
||||
SELECT d.user_id, d.id FROM downloads d;
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Build a database at the schema version *before* multi-user profiles, so
|
||||
/// the backfill is exercised against rows that predate it — which is the
|
||||
/// only state that matters, and the one an in-memory database created from
|
||||
/// the full migration list can never reproduce.
|
||||
fn pre_024_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let upto = MIGRATIONS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.expect("migration 024 must be registered");
|
||||
for (_, sql) in &MIGRATIONS[..upto] {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed(conn: &Connection, active_user: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn apply_024(conn: &Connection) {
|
||||
let (_, sql) = MIGRATIONS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, sql: &str) -> i64 {
|
||||
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
/// UT: upgrading an existing install does not blank its library. Without the
|
||||
/// backfill every cached item becomes invisible to the only user there is.
|
||||
#[test]
|
||||
fn backfill_keeps_the_existing_library_visible() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
|
||||
),
|
||||
1,
|
||||
"the active user must still see what was already cached"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an existing download becomes exactly one grant, held by whoever asked
|
||||
/// for it — the file is not orphaned and is not handed to anyone else.
|
||||
#[test]
|
||||
fn backfill_grants_downloads_to_their_requester() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a second profile added later starts with an empty view. The cache was
|
||||
/// filled by someone else's browsing and the server never showed it to them,
|
||||
/// so inheriting it is the leak this whole table exists to close.
|
||||
#[test]
|
||||
fn a_later_profile_inherits_nothing() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
|
||||
),
|
||||
0,
|
||||
"a profile added after the upgrade must not inherit another's cache"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
|
||||
/// library back — the fallback arm of the backfill.
|
||||
#[test]
|
||||
fn backfill_covers_an_install_with_no_active_flag() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: removing a profile takes its per-user rows with it, so a re-added
|
||||
/// account starts clean rather than resuming someone's stale view.
|
||||
#[test]
|
||||
fn removing_a_profile_cascades_its_rows() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user