- sessions.impersonator_id: Admin-Session behält Token, schaltet user_id aufs Ziel, Admin bleibt in impersonator_id gespeichert (Stack, keine Verschachtelung). uid()/Config/Dateien laufen als Ziel-Nutzer. - Admin sieht in /admin pro Nutzer "Anmelden als"; Bestätigungsdialog. - Dauerhaftes amber Banner im Header mit "Zurück zum Admin" (POST, kein JS nötig) erscheint auf jeder Seite während Impersonation. - requireAdmin verweigert während Impersonation -> keine Admin-Aktionen als fremder Nutzer; Stop-Route prüft impersonator_id (kein Escalation-Pfad für Normalnutzer). Selbst-Imitation blockiert. - audit_log-Tabelle protokolliert Start/Stop persistent; Admin-Seite zeigt Audit-Liste (/admin/audit/impersonations). - Migration: idempotentes ALTER ADD COLUMN impersonator_id fuer Bestand. Co-Authored-By: Claude <noreply@anthropic.com>
333 lines
15 KiB
JavaScript
333 lines
15 KiB
JavaScript
// Idempotent migration from the single-user schema to the multi-user schema.
|
|
//
|
|
// Used both at server boot (server.js calls runMigration after opening the DB)
|
|
// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly:
|
|
// every step guards itself with "already done?" checks.
|
|
//
|
|
// What it does, in order:
|
|
// 1. Create the `users` + `sessions` tables.
|
|
// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id.
|
|
// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable
|
|
// for upgraded installs — fresh installs create it NOT NULL directly) and
|
|
// backfill every existing row to the admin id.
|
|
// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the
|
|
// constraint becomes per-user: app_state, settings, prompts, design,
|
|
// jobangebote. Old rows are copied to the admin user.
|
|
// 5. Move on-disk attachment files into a per-user subdirectory for the admin.
|
|
// 6. One-time migration of any still-present .env values into the admin's cfg.
|
|
//
|
|
// After this, the app boots against a fully multi-user schema and all pre-existing
|
|
// data is owned by the admin user.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const password = require('./password');
|
|
const config = require('./config');
|
|
|
|
// Tables that need a `user_id` column added (legacy upgrades). Fresh installs
|
|
// create these with user_id NOT NULL directly in initializeDatabase().
|
|
const USER_TABLES = [
|
|
'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge',
|
|
'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge',
|
|
'termine', 'chat_threads', 'chat_messages',
|
|
'jobangebote_blacklist',
|
|
];
|
|
// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE
|
|
// constraint are installed by the recreate in step 4 (which needs user_id to be
|
|
// absent so the rebuild fires). Adding it here first would make step 4's
|
|
// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in
|
|
// place, breaking per-user isolation on ingest.
|
|
|
|
const ADMIN_USERNAME = 'admin';
|
|
const ADMIN_DEFAULT_PASSWORD = 'admin';
|
|
|
|
async function runMigration({ db, dbAll, dbGet, dbRun }) {
|
|
// Wire the config store to the same DB so config.setForUser works during the
|
|
// one-time .env migration below.
|
|
await config.init({ dbAll, dbRun });
|
|
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
|
|
|
const tableExists = async (name) => {
|
|
const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]);
|
|
return !!row;
|
|
};
|
|
|
|
const columnsOf = async (table) => {
|
|
const rows = await dbAll(`PRAGMA table_info(${table})`);
|
|
return rows.map((r) => r.name);
|
|
};
|
|
|
|
const hasColumn = async (table, col) => (await columnsOf(table)).includes(col);
|
|
|
|
// 1. users + sessions -------------------------------------------------
|
|
await exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
await exec(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
|
|
// Impersonation support: the admin's session keeps its token but switches
|
|
// user_id to the target, storing the original admin in impersonator_id so the
|
|
// admin can switch back. Added after the fact for existing installs.
|
|
if (!(await hasColumn('sessions', 'impersonator_id'))) {
|
|
await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER');
|
|
}
|
|
|
|
// 2. Ensure admin user (idempotent) -----------------------------------
|
|
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
|
if (!admin) {
|
|
const hash = password.hash(ADMIN_DEFAULT_PASSWORD);
|
|
const res = await dbRun(
|
|
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
|
|
[ADMIN_USERNAME, hash]
|
|
);
|
|
admin = { id: res.lastID };
|
|
console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`);
|
|
}
|
|
const adminId = admin.id;
|
|
|
|
// 3. Add user_id column + backfill ------------------------------------
|
|
for (const tbl of USER_TABLES) {
|
|
if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install
|
|
if (!(await hasColumn(tbl, 'user_id'))) {
|
|
await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`);
|
|
}
|
|
// Backfill every row that is not yet assigned (NULL) to the admin.
|
|
await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]);
|
|
}
|
|
|
|
// 4. Recreate tables whose PK/UNIQUE must be per-user -----------------
|
|
// app_state: PK(key) -> PK(user_id, key)
|
|
if (await tableExists('app_state')) {
|
|
if (!(await hasColumn('app_state', 'user_id'))) {
|
|
await recreate(db, dbAll, dbRun, 'app_state',
|
|
`CREATE TABLE app_state (
|
|
user_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT,
|
|
PRIMARY KEY (user_id, key),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`,
|
|
`INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`,
|
|
[adminId]
|
|
);
|
|
} else {
|
|
await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
|
}
|
|
}
|
|
|
|
// prompts: PK(key) -> PK(user_id, key)
|
|
if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) {
|
|
await recreate(db, dbAll, dbRun, 'prompts',
|
|
`CREATE TABLE prompts (
|
|
user_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL,
|
|
inhalt TEXT NOT NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, key),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`,
|
|
`INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`,
|
|
[adminId]
|
|
);
|
|
} else if (await tableExists('prompts')) {
|
|
await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
|
}
|
|
|
|
// design: PK(key) -> PK(user_id, key)
|
|
if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) {
|
|
await recreate(db, dbAll, dbRun, 'design',
|
|
`CREATE TABLE design (
|
|
user_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, key),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`,
|
|
`INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`,
|
|
[adminId]
|
|
);
|
|
} else if (await tableExists('design')) {
|
|
await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
|
}
|
|
|
|
// settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE)
|
|
if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) {
|
|
await recreate(db, dbAll, dbRun, 'settings',
|
|
`CREATE TABLE settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL UNIQUE,
|
|
name TEXT,
|
|
adresse TEXT,
|
|
kundennummer TEXT,
|
|
ort TEXT,
|
|
webseite TEXT,
|
|
email TEXT,
|
|
telefon TEXT,
|
|
geburtsdatum TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`,
|
|
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
|
SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`,
|
|
[adminId]
|
|
);
|
|
} else if (await tableExists('settings')) {
|
|
await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
|
}
|
|
|
|
// jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id)
|
|
if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) {
|
|
// user_id was added in step 3 already; this branch only recreates for the
|
|
// per-user UNIQUE constraint. Rebuild preserving all columns.
|
|
const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id');
|
|
const colList = cols.join(', ');
|
|
await recreate(db, dbAll, dbRun, 'jobangebote',
|
|
`CREATE TABLE jobangebote (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
external_id TEXT,
|
|
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
|
|
firma TEXT NOT NULL,
|
|
stelle TEXT NOT NULL,
|
|
ort TEXT,
|
|
adresse TEXT,
|
|
ansprechpartner TEXT,
|
|
gehalt TEXT,
|
|
beschreibung TEXT,
|
|
quelle_url TEXT,
|
|
art TEXT,
|
|
anzeige_datum DATE,
|
|
kontakt_email TEXT,
|
|
status TEXT NOT NULL DEFAULT 'offen',
|
|
verknuepfte_bewerbung_id INTEGER,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
url_norm TEXT,
|
|
firma_slug TEXT,
|
|
labels TEXT,
|
|
UNIQUE (user_id, quelle, external_id),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
|
)`,
|
|
`INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`,
|
|
[adminId]
|
|
);
|
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
|
}
|
|
|
|
// Ensure a settings row exists for the admin (idempotent). On a fresh install
|
|
// the settings table does not exist yet at this point (it is created by the
|
|
// server's CREATE TABLE IF NOT EXISTS right after the migration), so skip —
|
|
// the row is upserted on the admin's first /vorlagen save anyway.
|
|
if (await tableExists('settings')) {
|
|
const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]);
|
|
if (!srow) {
|
|
await dbRun(
|
|
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
|
VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`,
|
|
[adminId]
|
|
);
|
|
}
|
|
}
|
|
|
|
// 5. Move on-disk files into a per-user subdirectory for the admin -----
|
|
moveFilesIntoUserSubdir(adminId);
|
|
|
|
// 6. One-time env -> admin cfg import ----------------------------------
|
|
// On an upgraded install app_state already exists here, so this fills the
|
|
// admin's rows right away. On a *fresh* install the table is only created
|
|
// after this migration returns, so this is a no-op — server.js therefore
|
|
// calls importEnvIntoAdmin() again once the schema is complete. Idempotent
|
|
// either way (it only fills keys the admin has not stored).
|
|
await migrateEnvForAdmin(dbAll, adminId);
|
|
|
|
return { adminId };
|
|
}
|
|
|
|
// Import any still-present env config into the admin's rows. Called at boot from
|
|
// server.js, after initializeDatabase() has created app_state — this is what
|
|
// makes a deployment that passes its config as container env vars (as ours does)
|
|
// end up with those values owned by the admin, instead of leaking to every user
|
|
// through a read-time process.env fallback (which config.get() no longer has).
|
|
async function importEnvIntoAdmin({ dbAll, dbGet }) {
|
|
const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
|
if (!admin) return;
|
|
await migrateEnvForAdmin(dbAll, admin.id);
|
|
}
|
|
|
|
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
|
|
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
|
|
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
|
|
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
|
await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`);
|
|
await exec(newSchemaSql);
|
|
await dbRun(copySql, copyParams || []);
|
|
await exec(`DROP TABLE ${table}_old`);
|
|
}
|
|
|
|
// Move every file in each per-user storage directory into a `<userId>/` subdir.
|
|
// Idempotent: if the subdir already contains files (already migrated), leave the
|
|
// top-level files alone (they would be a re-run left-overs) — but on first run the
|
|
// top level holds the legacy flat files, which we move in.
|
|
const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto'];
|
|
function moveFilesIntoUserSubdir(userId) {
|
|
const dataDir = path.join(__dirname, '..', 'data');
|
|
for (const dir of STORAGE_DIRS) {
|
|
const base = path.join(dataDir, dir);
|
|
if (!fs.existsSync(base)) continue;
|
|
const userDir = path.join(base, String(userId));
|
|
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
|
|
for (const name of fs.readdirSync(base)) {
|
|
if (name === String(userId)) continue;
|
|
const src = path.join(base, name);
|
|
if (!fs.statSync(src).isFile()) continue; // skip subdirectories
|
|
const dst = path.join(userDir, name);
|
|
if (!fs.existsSync(dst)) fs.renameSync(src, dst);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move any still-present .env value for a DEFAULT key that the admin hasn't got
|
|
// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not
|
|
// yet present for the admin.
|
|
async function migrateEnvForAdmin(dbAll, adminId) {
|
|
// On a fresh install app_state does not exist yet at this point (created by
|
|
// the server right after the migration), so there is nothing to read from or
|
|
// write to — skip. The .env values, if any, then fall back via config.get().
|
|
try {
|
|
await dbAll('SELECT 1 FROM app_state LIMIT 1');
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]);
|
|
const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length)));
|
|
const migrated = [];
|
|
for (const key of Object.keys(config.DEFAULTS)) {
|
|
if (stored.has(key)) continue; // already in the DB, never overwrite from .env
|
|
const envVal = process.env[key];
|
|
if (envVal && envVal.length) {
|
|
await config.setForUser(adminId, key, envVal);
|
|
migrated.push(key);
|
|
}
|
|
}
|
|
if (migrated.length) {
|
|
console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`);
|
|
}
|
|
}
|
|
|
|
module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD }; |