diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index 131235f..9a74f55 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -9,7 +9,13 @@ import { declare module "express-serve-static-core" { interface Request { - user?: { id: string; username: string; role: "admin" | "member" }; + user?: { + id: string; + username: string; + role: "admin" | "member"; + capabilities?: Set; + bots?: "all" | Set; + }; } } diff --git a/src/web/middleware/requirePermission.test.ts b/src/web/middleware/requirePermission.test.ts new file mode 100644 index 0000000..f8bead8 --- /dev/null +++ b/src/web/middleware/requirePermission.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import { requirePermission, requireBotAccess } from "./requirePermission.js"; + +function appWith(user: any) { + const app = express(); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true })); + app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true })); + return app; +} + +const member = (caps: string[], bots: "all" | string[]) => ({ + id: "u1", username: "a", role: "member", + capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots), +}); +const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" }; + +describe("requirePermission", () => { + it("401 when unauthenticated", async () => { + const app = express(); + app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true })); + expect((await request(app).post("/cap")).status).toBe(401); + }); + it("403 when member lacks the capability", async () => { + expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403); + }); + it("200 when member has the capability", async () => { + expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200); + }); + it("200 for admin regardless of capabilities", async () => { + expect((await request(appWith(admin)).post("/cap")).status).toBe(200); + }); +}); + +describe("requireBotAccess", () => { + it("200 when bots = all", async () => { + expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200); + }); + it("200 when botId in allow-list", async () => { + expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200); + }); + it("403 when botId not in allow-list", async () => { + expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403); + }); + it("200 for admin", async () => { + expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200); + }); +}); diff --git a/src/web/middleware/requirePermission.ts b/src/web/middleware/requirePermission.ts new file mode 100644 index 0000000..5adfeb5 --- /dev/null +++ b/src/web/middleware/requirePermission.ts @@ -0,0 +1,19 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +export function requirePermission(capability: string): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} + +export function requireBotAccess(paramName = "botId"): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.bots === "all") { next(); return; } + const botId = req.params[paramName]; + if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +}