diff --git a/src/data/audit.test.ts b/src/data/audit.test.ts new file mode 100644 index 0000000..6714afc --- /dev/null +++ b/src/data/audit.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createAuditStore, type AuditStore } from "./audit.js"; + +describe("AuditStore", () => { + let botDb: BotDatabase; + let audit: AuditStore; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + audit = createAuditStore(botDb.db); + }); + + afterEach(() => botDb.close()); + + it("records and lists entries newest-first", async () => { + audit.record({ + actorId: "a1", actorUsername: "alice", + targetUserId: "b1", targetUsername: "bob", + action: "user.created", + }); + await new Promise((r) => setTimeout(r, 5)); + audit.record({ + actorId: "a1", actorUsername: "alice", + targetUserId: "b1", targetUsername: "bob", + action: "user.deleted", + }); + const list = audit.list(10, 0); + expect(list).toHaveLength(2); + expect(list[0].action).toBe("user.deleted"); + expect(list[1].action).toBe("user.created"); + }); + + it("supports limit and offset", () => { + for (let i = 0; i < 5; i++) { + audit.record({ + actorId: "a1", actorUsername: "alice", + targetUserId: null, targetUsername: null, + action: "user.password_changed", + }); + } + expect(audit.list(2, 0)).toHaveLength(2); + expect(audit.list(2, 4)).toHaveLength(1); + expect(audit.list(10, 10)).toHaveLength(0); + }); + + it("stores nullable fields correctly", () => { + audit.record({ + actorId: null, actorUsername: null, + targetUserId: "x", targetUsername: "deleted-user", + action: "admin.first_created", + }); + const e = audit.list(1, 0)[0]; + expect(e.actorId).toBeNull(); + expect(e.actorUsername).toBeNull(); + expect(e.targetUserId).toBe("x"); + }); +}); diff --git a/src/data/audit.ts b/src/data/audit.ts new file mode 100644 index 0000000..4e639de --- /dev/null +++ b/src/data/audit.ts @@ -0,0 +1,56 @@ +import type Database from "better-sqlite3"; + +export type AuditAction = + | "admin.first_created" + | "user.created" + | "user.deleted" + | "user.password_reset" + | "user.password_changed"; + +export interface AuditEntry { + id: number; + timestamp: number; + actorId: string | null; + actorUsername: string | null; + targetUserId: string | null; + targetUsername: string | null; + action: AuditAction; +} + +export interface AuditRecordInput { + actorId: string | null; + actorUsername: string | null; + targetUserId: string | null; + targetUsername: string | null; + action: AuditAction; +} + +export interface AuditStore { + record(input: AuditRecordInput): void; + list(limit: number, offset: number): AuditEntry[]; +} + +export function createAuditStore(db: Database.Database): AuditStore { + const insertStmt = db.prepare( + "INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)" + ); + const listStmt = db.prepare( + "SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?" + ); + + return { + record(input) { + insertStmt.run( + Date.now(), + input.actorId, + input.actorUsername, + input.targetUserId, + input.targetUsername, + input.action + ); + }, + list(limit, offset) { + return listStmt.all(limit, offset) as AuditEntry[]; + }, + }; +} diff --git a/src/data/database.test.ts b/src/data/database.test.ts index ad32b43..90436b8 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -40,6 +40,13 @@ describe("database", () => { expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]); }); + it("creates user_audit table on init", () => { + const tables = botDb.db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + expect(tables.map((t) => t.name)).toContain("user_audit"); + }); + it("records and retrieves play history", () => { botDb.addPlayHistory({ botId: "bot1", diff --git a/src/data/database.ts b/src/data/database.ts index 7dd79da..8d9fcf3 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -147,6 +147,17 @@ function initTables(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); + + CREATE TABLE IF NOT EXISTS user_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + actorId TEXT, + actorUsername TEXT, + targetUserId TEXT, + targetUsername TEXT, + action TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC); `); } diff --git a/src/web/api/audit.test.ts b/src/web/api/audit.test.ts new file mode 100644 index 0000000..90e27f9 --- /dev/null +++ b/src/web/api/audit.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore } from "../../data/users.js"; +import { createSessionStore } from "../../data/sessions.js"; +import { createAuditStore } from "../../data/audit.js"; +import { createRequireAuth } from "../middleware/requireAuth.js"; +import { createAuditRouter } from "./audit.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +describe("audit router", () => { + let botDb: BotDatabase; + let app: express.Express; + let cookie: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const audit = createAuditStore(botDb.db); + const alice = await users.createUser("alice", "pw-alice"); + cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; + for (let i = 0; i < 3; i++) { + audit.record({ + actorId: alice.id, actorUsername: "alice", + targetUserId: "x", targetUsername: "x", + action: "user.created", + }); + } + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api", createRequireAuth(sessions)); + app.use("/api/audit", createAuditRouter(audit)); + }); + + afterEach(() => botDb.close()); + + it("requires auth", async () => { + const res = await request(app).get("/api/audit"); + expect(res.status).toBe(401); + }); + + it("returns entries newest-first", async () => { + const res = await request(app).get("/api/audit").set("Cookie", cookie); + expect(res.status).toBe(200); + expect(res.body.entries).toHaveLength(3); + }); + + it("honors limit query param", async () => { + const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie); + expect(res.body.entries).toHaveLength(1); + }); +}); diff --git a/src/web/api/audit.ts b/src/web/api/audit.ts new file mode 100644 index 0000000..549454e --- /dev/null +++ b/src/web/api/audit.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import type { AuditStore } from "../../data/audit.js"; + +export function createAuditRouter(audit: AuditStore): Router { + const router = Router(); + router.get("/", (req, res) => { + const limit = clampInt(req.query.limit, 1, 500, 100); + const offset = clampInt(req.query.offset, 0, 100_000, 0); + res.json({ entries: audit.list(limit, offset) }); + }); + return router; +} + +function clampInt(v: unknown, min: number, max: number, def: number): number { + const n = typeof v === "string" ? parseInt(v, 10) : NaN; + if (!Number.isFinite(n)) return def; + return Math.min(Math.max(n, min), max); +} diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index 6545e73..b01f4ea 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -6,14 +6,16 @@ import pino from "pino"; import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; +import { createAuditStore } from "../../data/audit.js"; import { createSessionRouter } from "./session.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; -function makeApp(users: UserStore, sessions: SessionStore) { +function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { const app = express(); app.use(express.json()); app.use(cookieParser()); - app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" }))); + const audit = createAuditStore(botDb.db); + app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }))); return app; } @@ -35,7 +37,7 @@ describe("session router", () => { botDb = createDatabase(":memory:"); users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); - app = makeApp(users, sessions); + app = makeApp(botDb, users, sessions); }); afterEach(() => botDb.close()); diff --git a/src/web/api/session.ts b/src/web/api/session.ts index 5c00bbb..d45811f 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express"; import type { Logger } from "../../logger.js"; import type { UserStore } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; +import type { AuditStore } from "../../data/audit.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; @@ -47,6 +48,7 @@ function parseTokenFromCookie(cookieHeader: string | undefined): string | null { export function createSessionRouter( users: UserStore, sessions: SessionStore, + audit: AuditStore, logger: Logger ): Router { const router = Router(); @@ -86,6 +88,11 @@ export function createSessionRouter( } const { token } = sessions.createSession(user.id); setSessionCookie(res, token); + audit.record({ + actorId: user.id, actorUsername: user.username, + targetUserId: user.id, targetUsername: user.username, + action: "admin.first_created", + }); logger.info({ userId: user.id, username }, "First admin created"); res.json({ id: user.id, username: user.username }); } catch (err) { @@ -144,6 +151,11 @@ export function createSessionRouter( await users.changePassword(u.id, newPassword); const currentToken = parseTokenFromCookie(req.headers.cookie); sessions.deleteAllForUser(u.id, currentToken ?? undefined); + audit.record({ + actorId: u.id, actorUsername: u.username, + targetUserId: u.id, targetUsername: u.username, + action: "user.password_changed", + }); res.status(204).end(); }); diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index 63b0c92..b9c9744 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -6,17 +6,19 @@ import pino from "pino"; import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; +import { createAuditStore } from "../../data/audit.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createUsersRouter } from "./users.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; -function makeApp(users: UserStore, sessions: SessionStore) { +function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { const app = express(); app.use(express.json()); app.use(cookieParser()); const requireAuth = createRequireAuth(sessions); + const audit = createAuditStore(botDb.db); app.use("/api", requireAuth); - app.use("/api/users", createUsersRouter(users, sessions, pino({ level: "silent" }))); + app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }))); return app; } @@ -33,7 +35,7 @@ describe("users router", () => { botDb = createDatabase(":memory:"); users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); - app = makeApp(users, sessions); + app = makeApp(botDb, users, sessions); const alice = await users.createUser("alice", "pw-alice"); aliceId = alice.id; aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; diff --git a/src/web/api/users.ts b/src/web/api/users.ts index b837e77..79e89dc 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js"; import type { UserStore } from "../../data/users.js"; import { UsernameTakenError } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; +import type { AuditStore } from "../../data/audit.js"; function isValidUsername(v: unknown): v is string { return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); @@ -15,6 +16,7 @@ function isValidPassword(v: unknown): v is string { export function createUsersRouter( users: UserStore, sessions: SessionStore, + audit: AuditStore, logger: Logger ): Router { const router = Router(); @@ -31,6 +33,11 @@ export function createUsersRouter( } try { const u = await users.createUser(username, password); + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: u.id, targetUsername: u.username, + action: "user.created", + }); logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created"); res.status(201).json({ id: u.id, username: u.username }); } catch (err) { @@ -45,6 +52,11 @@ export function createUsersRouter( router.delete("/:id", (req, res) => { const targetId = req.params.id; + const target = users.findById(targetId); + if (!target) { + res.status(404).json({ error: "not found" }); + return; + } if (targetId === req.user!.id) { res.status(400).json({ error: "cannot delete self" }); return; @@ -55,6 +67,11 @@ export function createUsersRouter( return; } sessions.deleteAllForUser(targetId); + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: target.id, targetUsername: target.username, + action: "user.deleted", + }); logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted"); res.status(204).end(); }); @@ -75,6 +92,11 @@ export function createUsersRouter( // Invalidate all sessions for the target user (except current actor's if it's the same user) const exceptToken = targetId === req.user!.id ? undefined : undefined; sessions.deleteAllForUser(targetId, exceptToken); + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: target.id, targetUsername: target.username, + action: "user.password_reset", + }); logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset"); res.status(204).end(); }); diff --git a/src/web/server.ts b/src/web/server.ts index 1424f46..d596d72 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -16,6 +16,8 @@ import { createPlayerRouter } from "./api/player.js"; import { createAuthRouter } from "./api/auth.js"; import { createSessionRouter } from "./api/session.js"; import { createUsersRouter } from "./api/users.js"; +import { createAuditStore } from "../data/audit.js"; +import { createAuditRouter } from "./api/audit.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; @@ -59,6 +61,7 @@ export function createWebServer(options: WebServerOptions): WebServer { const users = createUserStore(options.database.db); const sessions = createSessionStore(options.database.db); + const audit = createAuditStore(options.database.db); // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── app.get("/api/health", (_req, res) => { @@ -70,7 +73,7 @@ export function createWebServer(options: WebServerOptions): WebServer { res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); }); - app.use("/api/session", createSessionRouter(users, sessions, logger)); + app.use("/api/session", createSessionRouter(users, sessions, audit, logger)); // ─── Gates for everything else under /api ─────────────────────────────── const requireAuth = createRequireAuth(sessions); @@ -101,7 +104,8 @@ export function createWebServer(options: WebServerOptions): WebServer { "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); - app.use("/api/users", createUsersRouter(users, sessions, logger)); + app.use("/api/users", createUsersRouter(users, sessions, audit, logger)); + app.use("/api/audit", createAuditRouter(audit)); // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) {