diff --git a/src/data/audit.ts b/src/data/audit.ts index 6f48171..c3d8004 100644 --- a/src/data/audit.ts +++ b/src/data/audit.ts @@ -6,7 +6,8 @@ export type AuditAction = | "user.deleted" | "user.password_reset" | "user.password_changed" - | "user.role_changed"; + | "user.role_changed" + | "user.permissions_changed"; export interface AuditEntry { id: number; diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index 669395e..35e7c43 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -6,8 +6,8 @@ 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 { createPermissionStore } from "../../data/permissions.js"; +import { createAuditStore, type AuditStore } from "../../data/audit.js"; +import { createPermissionStore, type PermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createUsersRouter } from "./users.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -20,8 +20,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { const requireAuth = createRequireAuth(sessions, permissions); const audit = createAuditStore(botDb.db); app.use("/api", requireAuth); - app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }))); - return app; + app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); + return { app, permissions, audit }; } describe("users router", () => { @@ -29,6 +29,8 @@ describe("users router", () => { let users: UserStore; let sessions: SessionStore; let app: express.Express; + let permissions: PermissionStore; + let audit: AuditStore; let aliceId: string; let aliceCookie: string; let bobId: string; @@ -37,7 +39,7 @@ describe("users router", () => { botDb = createDatabase(":memory:"); users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); - app = makeApp(botDb, users, sessions); + ({ app, permissions, audit } = makeApp(botDb, users, sessions)); const alice = await users.createUser("alice", "pw-alice", "admin"); aliceId = alice.id; aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; @@ -153,7 +155,7 @@ describe("users router", () => { localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); localApp.use( "/api/users", - createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" })) + createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db)) ); const res = await request(localApp) .post("/api/users") @@ -256,4 +258,87 @@ describe("users router", () => { .send({ role: "admin" }); expect(res.status).toBe(404); }); + + it("GET /:id/permissions returns empty arrays for a fresh member", async () => { + const res = await request(app) + .get(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual({ capabilities: [], bots: [] }); + }); + + it("GET /:id/permissions 404 on unknown user", async () => { + const res = await request(app) + .get(`/api/users/not-a-real-id/permissions`) + .set("Cookie", aliceCookie); + expect(res.status).toBe(404); + }); + + it("PUT /:id/permissions sets permissions, persists, and audits", async () => { + const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed"); + expect(before).toHaveLength(0); + + const put = await request(app) + .put(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control"], bots: "all" }); + expect(put.status).toBe(200); + + const get = await request(app) + .get(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie); + expect(get.status).toBe(200); + expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" }); + + const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed"); + expect(rows).toHaveLength(1); + expect(rows[0].actorId).toBe(aliceId); + expect(rows[0].targetUserId).toBe(bobId); + }); + + it("PUT /:id/permissions drops unknown capability tokens", async () => { + const put = await request(app) + .put(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control", "bogus"], bots: [] }); + expect(put.status).toBe(200); + expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]); + }); + + it("PUT /:id/permissions 404 on unknown user", async () => { + const res = await request(app) + .put(`/api/users/not-a-real-id/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control"], bots: "all" }); + expect(res.status).toBe(404); + }); + + it("POST / seeds the basic tier for a new member", async () => { + const res = await request(app) + .post("/api/users") + .set("Cookie", aliceCookie) + .send({ username: "dave", password: "dave-pw-pw" }); + expect(res.status).toBe(201); + const perms = await request(app) + .get(`/api/users/${res.body.id}/permissions`) + .set("Cookie", aliceCookie); + expect(perms.status).toBe(200); + expect(perms.body).toEqual({ + capabilities: ["player.control", "player.queue"], + bots: "all", + }); + }); + + it("POST / does NOT seed permissions for a new admin", async () => { + const res = await request(app) + .post("/api/users") + .set("Cookie", aliceCookie) + .send({ username: "erin", password: "erin-pw-pw", role: "admin" }); + expect(res.status).toBe(201); + const perms = await request(app) + .get(`/api/users/${res.body.id}/permissions`) + .set("Cookie", aliceCookie); + expect(perms.status).toBe(200); + expect(perms.body).toEqual({ capabilities: [], bots: [] }); + }); }); diff --git a/src/web/api/users.ts b/src/web/api/users.ts index 0517654..7d38236 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -4,6 +4,7 @@ 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"; +import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js"; import { extractSessionToken } from "../auth/validateSession.js"; function isValidUsername(v: unknown): v is string { @@ -18,7 +19,8 @@ export function createUsersRouter( users: UserStore, sessions: SessionStore, audit: AuditStore, - logger: Logger + logger: Logger, + permissions: PermissionStore ): Router { const router = Router(); @@ -35,6 +37,9 @@ export function createUsersRouter( const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member"; try { const u = await users.createUser(username, password, role); + if (u.role === "member") { + permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" }); + } try { audit.record({ actorId: req.user!.id, actorUsername: req.user!.username, @@ -162,5 +167,43 @@ export function createUsersRouter( res.status(204).end(); }); + router.get("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json({ + capabilities: permissions.getCapabilities(user.id), + bots: permissions.getBotAccess(user.id), + }); + }); + + router.put("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { + res.status(404).json({ error: "not_found" }); + return; + } + const body = req.body ?? {}; + const caps: string[] = Array.isArray(body.capabilities) + ? body.capabilities.filter(isCapability) + : []; + const bots: "all" | string[] = + body.bots === "all" ? "all" : Array.isArray(body.bots) ? body.bots.map(String) : []; + permissions.setPermissions(user.id, { capabilities: caps, bots }); + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: user.id, targetUsername: user.username, + action: "user.permissions_changed", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.permissions_changed" }, "audit insert failed"); + } + logger.info({ actorId: req.user!.id, targetUserId: user.id }, "User permissions changed"); + res.json({ success: true }); + }); + return router; } diff --git a/src/web/server.ts b/src/web/server.ts index 78e09e2..d20cdb5 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -126,7 +126,7 @@ export function createWebServer(options: WebServerOptions): WebServer { createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); // admin-only routes - app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger)); + app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); app.use("/api/audit", requireAdmin, createAuditRouter(audit)); // ─── Static SPA (public) ────────────────────────────────────────────────