feat(auth): two-role permission system (admin/member)

Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-27 15:35:15 +08:00
1 parent b0b61f8fce
commit a73f797bcb
18 files changed
+292 -60

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
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 { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAdmin middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminCookie: string;
let memberCookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
afterEach(() => botDb.close());
it("rejects unauthenticated requests with 401", async () => {
const res = await request(app).get("/admin-only");
expect(res.status).toBe(401);
});
it("rejects member with 403", async () => {
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "forbidden" });
});
it("allows admin", async () => {
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
expect(res.status).toBe(200);
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "unauthenticated" });
return;
}
if (req.user.role !== "admin") {
res.status(403).json({ error: "forbidden" });
return;
}
next();
}
+2 -1
View File
@@ -17,7 +17,7 @@ describe("requireAuth middleware", () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw");
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
@@ -52,6 +52,7 @@ describe("requireAuth middleware", () => {
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
});
it("rolls the cookie max-age forward on successful auth", async () => {
+2 -2
View File
@@ -9,7 +9,7 @@ import {
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; username: string };
user?: { id: string; username: string; role: "admin" | "member" };
}
}
@@ -21,7 +21,7 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler {
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username };
req.user = { id: result.userId, username: result.username, role: result.role };
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {