feat(auth): Story 1.1 — Auth-Schema (users/tokens/sessions/audit) + foreign_keys + audit-Helper
Fuegt users/login_tokens/sessions/audit_log Tabellen + FK-Indizes zu server/db.js hinzu (foreign_keys=ON fuer CASCADE), plus server/audit.js (append-only, keine PII). Bestehende documents/history-Tabellen und exports unveraendert. TDD: server/auth-schema.test.js zuerst rot, dann gruen; volle Suite (24 Tests) regressionsfrei.
This commit is contained in:
parent
94965c583f
commit
b2dbd31b41
3 changed files with 115 additions and 0 deletions
9
server/audit.js
Normal file
9
server/audit.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const { db } = require('./db');
|
||||
|
||||
// Append-only Audit-Log. Keine PII (kein E-Mail/Name) — nur event, user_id, ip_hash.
|
||||
function audit(event, userId = null, ipHash = null) {
|
||||
db.prepare('INSERT INTO audit_log (ts, event, user_id, ip_hash) VALUES (?, ?, ?, ?)')
|
||||
.run(new Date().toISOString(), event, userId, ipHash);
|
||||
}
|
||||
|
||||
module.exports = { audit };
|
||||
77
server/auth-schema.test.js
Normal file
77
server/auth-schema.test.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// DATA_DIR auf Temp umbiegen, BEVOR db.js geladen wird
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-auth-schema-'));
|
||||
const { db } = require('./db');
|
||||
const { audit } = require('./audit');
|
||||
|
||||
function tableNames() {
|
||||
return db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
||||
}
|
||||
|
||||
function indexNames() {
|
||||
return db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map((r) => r.name);
|
||||
}
|
||||
|
||||
test('Auth-Tabellen existieren', () => {
|
||||
const names = tableNames();
|
||||
assert.ok(names.includes('users'), 'users fehlt');
|
||||
assert.ok(names.includes('login_tokens'), 'login_tokens fehlt');
|
||||
assert.ok(names.includes('sessions'), 'sessions fehlt');
|
||||
assert.ok(names.includes('audit_log'), 'audit_log fehlt');
|
||||
});
|
||||
|
||||
test('foreign_keys ist aktiv', () => {
|
||||
assert.strictEqual(db.pragma('foreign_keys', { simple: true }), 1);
|
||||
});
|
||||
|
||||
test('Indizes auf login_tokens.user_id und sessions.user_id existieren', () => {
|
||||
const names = indexNames();
|
||||
assert.ok(names.includes('idx_login_tokens_user'), 'idx_login_tokens_user fehlt');
|
||||
assert.ok(names.includes('idx_sessions_user'), 'idx_sessions_user fehlt');
|
||||
});
|
||||
|
||||
test('foreign_keys CASCADE: Nutzer löschen räumt tokens+sessions', () => {
|
||||
const now = '2026-07-07T10:00:00.000Z';
|
||||
const userId = db.prepare('INSERT INTO users (email, is_admin, created_at) VALUES (?, 0, ?)')
|
||||
.run('cascade-test@example.com', now).lastInsertRowid;
|
||||
db.prepare('INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run('hash-cascade', userId, now);
|
||||
db.prepare('INSERT INTO sessions (id_hash, user_id, expires_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('sess-cascade', userId, now, now, now);
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
|
||||
assert.strictEqual(db.prepare('SELECT * FROM login_tokens WHERE user_id = ?').get(userId), undefined);
|
||||
assert.strictEqual(db.prepare('SELECT * FROM sessions WHERE user_id = ?').get(userId), undefined);
|
||||
});
|
||||
|
||||
test('audit() schreibt append-only Zeile ohne PII', () => {
|
||||
const before = db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n;
|
||||
audit('login_ok', 1, 'abc');
|
||||
const after = db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n;
|
||||
assert.strictEqual(after, before + 1);
|
||||
|
||||
const row = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT 1').get();
|
||||
assert.strictEqual(row.event, 'login_ok');
|
||||
assert.strictEqual(row.user_id, 1);
|
||||
assert.strictEqual(row.ip_hash, 'abc');
|
||||
assert.ok(row.ts, 'ts fehlt');
|
||||
// keine PII-Spalten (email/name) vorhanden
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(row).sort(),
|
||||
['event', 'id', 'ip_hash', 'ts', 'user_id'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test('audit() ohne userId/ipHash (defaults null)', () => {
|
||||
audit('server_start');
|
||||
const row = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT 1').get();
|
||||
assert.strictEqual(row.event, 'server_start');
|
||||
assert.strictEqual(row.user_id, null);
|
||||
assert.strictEqual(row.ip_hash, null);
|
||||
});
|
||||
29
server/db.js
29
server/db.js
|
|
@ -9,6 +9,7 @@ const DB_PATH = path.join(DATA_DIR, 'dienstplan.db');
|
|||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
|
|
@ -22,6 +23,34 @@ db.exec(`
|
|||
value TEXT NOT NULL,
|
||||
replaced_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
ip_hash TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_tokens_user ON login_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
`);
|
||||
|
||||
function getDoc(key) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue