diff --git a/src/web/middleware/authorize.test.ts b/src/web/middleware/authorize.test.ts new file mode 100644 index 0000000..15e3586 --- /dev/null +++ b/src/web/middleware/authorize.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { authorize } from "./authorize.js"; + +function run(user: any, opts: any) { + const req: any = { user }; + const res: any = { statusCode: 0, body: null, status(c: number) { this.statusCode = c; return this; }, json(b: any) { this.body = b; return this; } }; + const next = vi.fn(); + authorize(opts)(req, res, next); + return { res, next }; +} + +describe("authorize", () => { + it("401 when unauthenticated", () => { + const { res, next } = run(undefined, { capability: "player.queue" }); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + it("admin always passes", () => { + const { next } = run({ role: "admin" }, { capability: "bot.manage" }); + expect(next).toHaveBeenCalled(); + }); + it("member passes only with the capability", () => { + expect(run({ role: "member", capabilities: new Set(["player.queue"]) }, { capability: "player.queue" }).next).toHaveBeenCalled(); + expect(run({ role: "member", capabilities: new Set() }, { capability: "player.queue" }).res.statusCode).toBe(403); + }); + it("guest passes only when its flag is enabled", () => { + expect(run({ role: "guest", guest: { playNext: true } }, { capability: "player.control", guestFlag: "playNext" }).next).toHaveBeenCalled(); + expect(run({ role: "guest", guest: { playNext: false } }, { capability: "player.control", guestFlag: "playNext" }).res.statusCode).toBe(403); + }); + it("guest is denied on routes with no guestFlag (e.g. play-song)", () => { + expect(run({ role: "guest", guest: { addToQueue: true } }, { capability: "player.control" }).res.statusCode).toBe(403); + }); +}); diff --git a/src/web/middleware/authorize.ts b/src/web/middleware/authorize.ts new file mode 100644 index 0000000..d5e39cb --- /dev/null +++ b/src/web/middleware/authorize.ts @@ -0,0 +1,29 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { GuestFlag } from "../../data/permissions.js"; + +/** + * Unified authorization gate. + * - admin → always allowed (unchanged from requirePermission) + * - member → allowed iff it holds `capability` (unchanged from requirePermission) + * - guest → allowed iff `guestFlag` is set AND that flag is enabled in the + * guest's resolved permissions; a route with no `guestFlag` is + * denied to guests by default. + * Generic over the route-param shape `P` for the same reason requirePermission is. + */ +export function authorize
>(opts: { + capability?: string; + guestFlag?: GuestFlag; +}): RequestHandler
{ + return (req: Request
, res: Response, next: NextFunction) => { + const user = req.user; + if (!user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (user.role === "admin") { next(); return; } + if (user.role === "guest") { + if (opts.guestFlag && user.guest?.[opts.guestFlag]) { next(); return; } + res.status(403).json({ error: "forbidden" }); + return; + } + if (opts.capability && user.capabilities?.has(opts.capability)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} diff --git a/src/web/middleware/requireNotGuest.test.ts b/src/web/middleware/requireNotGuest.test.ts new file mode 100644 index 0000000..e1de942 --- /dev/null +++ b/src/web/middleware/requireNotGuest.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import { requireNotGuest } from "./requireNotGuest.js"; + +function run(user: any) { + const req: any = { user }; + const res: any = { statusCode: 0, status(c: number) { this.statusCode = c; return this; }, json() { return this; } }; + const next = vi.fn(); + requireNotGuest(req, res, next); + return { res, next }; +} + +describe("requireNotGuest", () => { + it("401 when no user", () => { expect(run(undefined).res.statusCode).toBe(401); }); + it("403 for guests", () => { expect(run({ role: "guest" }).res.statusCode).toBe(403); }); + it("passes admins and members", () => { + expect(run({ role: "admin" }).next).toHaveBeenCalled(); + expect(run({ role: "member" }).next).toHaveBeenCalled(); + }); +}); diff --git a/src/web/middleware/requireNotGuest.ts b/src/web/middleware/requireNotGuest.ts new file mode 100644 index 0000000..1d81290 --- /dev/null +++ b/src/web/middleware/requireNotGuest.ts @@ -0,0 +1,9 @@ +import type { Request, Response, NextFunction } from "express"; + +/** Allow admins and members; deny login-less guests (used for config reads + * that must never leak to guests, e.g. GET /api/bot/settings, GET /api/music/quality). */ +export function requireNotGuest(req: Request, res: Response, next: NextFunction): void { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "guest") { res.status(403).json({ error: "forbidden" }); return; } + next(); +}