keep audit trails and recordings when a user is deleted (#1128)

audit_logs and session_recordings both referenced users with ON DELETE CASCADE,
so removing an account erased everything it had ever done. An audit trail that
disappears with the account it recorded cannot answer the question it exists for,
and a recording is evidence about a host as much as about a person.

Both foreign keys become ON DELETE SET NULL. audit_logs already denormalises
username, so an entry still names who acted once the reference is gone.
session_recordings did not, so the column is added and backfilled first —
otherwise relaxing the constraint would only trade deleted evidence for
anonymous evidence.

SQLite cannot alter a foreign key in place, so existing databases are migrated
by copy-and-swap, guarded by a PRAGMA check that makes it idempotent. Fresh
databases are created in the target shape and skip it. Recordings still cascade
from their host.
This commit is contained in:
ZacharyZcR
2026-07-28 17:01:33 +08:00
committed by GitHub
parent 64a80f411a
commit 768c64bd6a
5 changed files with 431 additions and 12 deletions
+11 -5
View File
@@ -8,6 +8,7 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseMigration } from "../../utils/database-migration.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { migrateAuditRetention } from "../../utils/audit-retention-migration.js";
import {
assertDataDirIsNotMisconfigured,
DataDirMisconfiguredError,
@@ -482,13 +483,14 @@ async function initializeCompleteDatabase(): Promise<void> {
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
@@ -501,7 +503,7 @@ async function initializeCompleteDatabase(): Promise<void> {
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
@@ -1647,7 +1649,7 @@ const migrateSchema = () => {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
user_id TEXT,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
@@ -1659,7 +1661,7 @@ const migrateSchema = () => {
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
`);
} catch (createError) {
@@ -2507,6 +2509,10 @@ const migrateSchema = () => {
}
// --- sync end ---
// Audit trails and session recordings used to be deleted along with the user
// they referenced, which defeats the point of keeping them.
migrateAuditRetention(sqlite);
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});