mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration: - player.ts: kept #88's POST /:botId/fm route AND gated it with requirePermission('player.control') so the new control endpoint honors #80's permission model (it was added without gating). - bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated POST /settings with requirePermission('bot.manage'). - Navbar.vue: composed #82's dedicated-link scope with #80's permission filter — displayedBots is now the INTERSECTION (scope ∩ controllable allow-list). - database.ts: kept BOTH new table sets (#87 favorite_playlists + #80 user_permissions/user_bot_access). - bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new two-arg signature. #80 review fixes (credential exposure / IDOR, adversarially verified): - GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey + identity from the response (was readable by any authenticated member). - GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
This commit is contained in:
commit
bea2f92508
32 files changed
+2225
-137
No files matched your search
@@ -6,6 +6,7 @@ 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 { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createAuditRouter } from "./audit.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -20,6 +21,7 @@ describe("audit router", () => {
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -32,7 +34,7 @@ describe("audit router", () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions));
|
||||
app.use("/api", createRequireAuth(sessions, permissions));
|
||||
app.use("/api/audit", createAuditRouter(audit));
|
||||
});
|
||||
|
||||
|
||||
+5
-4
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { CookieStore } from "../../music/auth.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
|
||||
export function createAuthRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -35,7 +36,7 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/qrcode", async (req, res) => {
|
||||
router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.body;
|
||||
const provider = getProvider(platform);
|
||||
@@ -77,7 +78,7 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/sms/send", async (req, res) => {
|
||||
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
|
||||
try {
|
||||
const { phone } = req.body;
|
||||
if (!phone) {
|
||||
@@ -97,7 +98,7 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/sms/verify", async (req, res) => {
|
||||
router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => {
|
||||
try {
|
||||
const { phone, code } = req.body;
|
||||
if (!phone || !code) {
|
||||
@@ -118,7 +119,7 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/cookie", (req, res) => {
|
||||
router.post("/cookie", requirePermission("platform.auth"), (req, res) => {
|
||||
const { platform, cookie } = req.body;
|
||||
if (!cookie) {
|
||||
res.status(400).json({ error: "cookie is required" });
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// Fake bot whose getStatus() exposes its id, matching the real status shape.
|
||||
function makeFakeBot(id: string) {
|
||||
return {
|
||||
id,
|
||||
getStatus: () => ({ id }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBotManager() {
|
||||
const b1 = makeFakeBot("b1");
|
||||
const b2 = makeFakeBot("b2");
|
||||
return {
|
||||
getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined),
|
||||
getAllBots: () => [b1, b2],
|
||||
getBotConfig: () => undefined,
|
||||
createBot: async () => b1,
|
||||
updateBot: () => {},
|
||||
removeBot: async () => {},
|
||||
startBot: async () => {},
|
||||
stopBot: () => {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeApp(user: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(
|
||||
makeBotManager(),
|
||||
{ idleTimeoutMinutes: 0 } as any,
|
||||
"/tmp/config.json",
|
||||
logger,
|
||||
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
|
||||
{ read: () => null, write: () => "x", remove: () => {} } as any,
|
||||
),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
const member = (bots: "all" | string[]) => ({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
role: "member" as const,
|
||||
capabilities: new Set<string>(),
|
||||
bots: bots === "all" ? ("all" as const) : new Set(bots),
|
||||
});
|
||||
|
||||
const admin = {
|
||||
id: "a",
|
||||
username: "admin",
|
||||
role: "admin" as const,
|
||||
capabilities: new Set<string>(),
|
||||
bots: "all" as const,
|
||||
};
|
||||
|
||||
describe("GET /api/bot bot-list filtering", () => {
|
||||
it("member with bots:Set([b1]) sees only b1", async () => {
|
||||
const app = makeApp(member(["b1"]));
|
||||
const res = await request(app).get("/api/bot");
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.bots as { id: string }[]).map((b) => b.id);
|
||||
expect(ids).toEqual(["b1"]);
|
||||
});
|
||||
|
||||
it("admin sees both b1 and b2", async () => {
|
||||
const app = makeApp(admin);
|
||||
const res = await request(app).get("/api/bot");
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
|
||||
expect(ids).toEqual(["b1", "b2"]);
|
||||
});
|
||||
|
||||
it("member with bots:'all' sees both b1 and b2", async () => {
|
||||
const app = makeApp(member("all"));
|
||||
const res = await request(app).get("/api/bot");
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
|
||||
expect(ids).toEqual(["b1", "b2"]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createAvatarStore } from "../../data/avatars.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -59,7 +60,7 @@ describe("bot router /settings", () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions));
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||
|
||||
+25
-15
@@ -5,6 +5,7 @@ import { saveConfig } from "../../data/config.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotDatabase } from "../../data/database.js";
|
||||
import type { AvatarStore } from "../../data/avatars.js";
|
||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||
|
||||
export function createBotRouter(
|
||||
botManager: BotManager,
|
||||
@@ -16,8 +17,13 @@ export function createBotRouter(
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const bots = botManager.getAllBots().map((b) => b.getStatus());
|
||||
router.get("/", (req, res) => {
|
||||
const all = botManager.getAllBots().map((b) => b.getStatus());
|
||||
const u = req.user!;
|
||||
const bots =
|
||||
u.role === "admin" || u.bots === "all"
|
||||
? all
|
||||
: all.filter((b) => u.bots instanceof Set && u.bots.has(b.id));
|
||||
res.json({ bots });
|
||||
});
|
||||
|
||||
@@ -30,8 +36,9 @@ export function createBotRouter(
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||
router.post("/settings", (req, res) => {
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||
|
||||
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||
@@ -58,7 +65,7 @@ export function createBotRouter(
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
router.get("/:id", requireBotAccess("id"), (req, res) => {
|
||||
const bot = botManager.getBot(req.params.id);
|
||||
if (!bot) {
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
@@ -68,16 +75,19 @@ export function createBotRouter(
|
||||
});
|
||||
|
||||
// Get saved config for a bot
|
||||
router.get("/:id/config", (req, res) => {
|
||||
router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||
const saved = botManager.getBotConfig(req.params.id);
|
||||
if (!saved) {
|
||||
res.status(404).json({ error: "Bot config not found" });
|
||||
return;
|
||||
}
|
||||
res.json(saved);
|
||||
// Never expose the TS identity / API key to the client; the edit form only
|
||||
// consumes channel/server passwords.
|
||||
const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record<string, unknown>;
|
||||
res.json(safe);
|
||||
});
|
||||
|
||||
router.get("/:id/avatar", (req, res) => {
|
||||
router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||
if (!path) {
|
||||
res.status(404).end();
|
||||
@@ -99,7 +109,7 @@ export function createBotRouter(
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
router.put("/:id/avatar", (req, res) => {
|
||||
router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||
const exists =
|
||||
botManager.getBot(req.params.id) ||
|
||||
botDb.getBotInstances().some((b) => b.id === req.params.id);
|
||||
@@ -133,7 +143,7 @@ export function createBotRouter(
|
||||
res.json({ path: rel });
|
||||
});
|
||||
|
||||
router.delete("/:id/avatar", (req, res) => {
|
||||
router.delete("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||
if (path) avatarStore.remove(path);
|
||||
botDb.setCustomAvatarPath(req.params.id, null);
|
||||
@@ -141,7 +151,7 @@ export function createBotRouter(
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
router.post("/", requirePermission("bot.manage"), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
@@ -177,7 +187,7 @@ export function createBotRouter(
|
||||
});
|
||||
|
||||
// Update bot config (must be stopped first to apply connection changes)
|
||||
router.put("/:id", async (req, res) => {
|
||||
router.put("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||
try {
|
||||
const bot = botManager.getBot(req.params.id);
|
||||
if (!bot) {
|
||||
@@ -196,7 +206,7 @@ export function createBotRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req, res) => {
|
||||
router.delete("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||
try {
|
||||
await botManager.removeBot(req.params.id);
|
||||
res.json({ success: true });
|
||||
@@ -205,7 +215,7 @@ export function createBotRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/start", async (req, res) => {
|
||||
router.post("/:id/start", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||
try {
|
||||
await botManager.startBot(req.params.id);
|
||||
res.json({ success: true });
|
||||
@@ -214,7 +224,7 @@ export function createBotRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/stop", (req, res) => {
|
||||
router.post("/:id/stop", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||
try {
|
||||
botManager.stopBot(req.params.id);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -217,7 +218,7 @@ export function createMusicRouter(
|
||||
});
|
||||
|
||||
// Set quality
|
||||
router.post("/quality", (req, res) => {
|
||||
router.post("/quality", requirePermission("quality"), (req, res) => {
|
||||
const { quality, platform } = req.body;
|
||||
if (!quality) {
|
||||
res.status(400).json({ error: "quality is required" });
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { createPlayerRouter } from "./player.js";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
import { createAuthRouter } from "./auth.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// --- minimal stubs --------------------------------------------------------
|
||||
|
||||
const ALLOWED_BOT = "bot-allowed";
|
||||
|
||||
// A fake bot whose methods all no-op / return benign values so the real
|
||||
// handlers run to completion without 500ing. We only assert that the
|
||||
// permission/bot-access gate let the request THROUGH (status !== 403).
|
||||
function makeFakeBot(id: string) {
|
||||
return {
|
||||
id,
|
||||
executeCommand: async () => "ok",
|
||||
getStatus: () => ({ id }),
|
||||
getQueue: () => [],
|
||||
getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBotManager() {
|
||||
const bot = makeFakeBot(ALLOWED_BOT);
|
||||
return {
|
||||
getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined),
|
||||
getAllBots: () => [bot],
|
||||
getBotConfig: () => undefined,
|
||||
createBot: async () => bot,
|
||||
updateBot: () => {},
|
||||
removeBot: async () => {},
|
||||
startBot: async () => {},
|
||||
stopBot: () => {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeProvider() {
|
||||
return {
|
||||
platform: "netease",
|
||||
getQuality: () => "high",
|
||||
setQuality: () => {},
|
||||
getAuthStatus: async () => ({ loggedIn: false }),
|
||||
getQrCode: async () => ({ key: "k", url: "u" }),
|
||||
getCookie: () => "c",
|
||||
setCookie: () => {},
|
||||
search: async () => ({ songs: [], albums: [], playlists: [] }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Build one app mounting all four real routers, with req.user injected by a
|
||||
// middleware placed BEFORE the routers (mimicking what requireAuth does).
|
||||
function makeApp(user: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||
|
||||
const botManager = makeBotManager();
|
||||
const provider = makeProvider();
|
||||
|
||||
app.use("/api/player", createPlayerRouter(botManager, logger));
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(
|
||||
botManager,
|
||||
{ idleTimeoutMinutes: 0 } as any,
|
||||
"/tmp/config.json",
|
||||
logger,
|
||||
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
|
||||
{ read: () => null, write: () => "x", remove: () => {} } as any,
|
||||
),
|
||||
);
|
||||
app.use("/api/auth", createAuthRouter(provider, provider, provider, logger));
|
||||
app.use("/api/music", createMusicRouter(provider, provider, provider, logger));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
const member = (caps: string[], bots: "all" | string[]) => ({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
role: "member" as const,
|
||||
capabilities: new Set(caps),
|
||||
bots: bots === "all" ? ("all" as const) : new Set(bots),
|
||||
});
|
||||
|
||||
const admin = {
|
||||
id: "a",
|
||||
username: "admin",
|
||||
role: "admin" as const,
|
||||
capabilities: new Set<string>(),
|
||||
bots: "all" as const,
|
||||
};
|
||||
|
||||
describe("permission enforcement on action routes", () => {
|
||||
describe("player.control", () => {
|
||||
it("403 for member WITHOUT player.control", async () => {
|
||||
const app = makeApp(member([], [ALLOWED_BOT]));
|
||||
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH player.control + bot in allow-list", async () => {
|
||||
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
||||
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("403 for member WITH player.control but bot NOT in allow-list", async () => {
|
||||
const app = makeApp(member(["player.control"], ["other-bot"]));
|
||||
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("player.queue", () => {
|
||||
it("403 for member WITHOUT player.queue", async () => {
|
||||
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
||||
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH player.queue", async () => {
|
||||
const app = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
||||
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bot.manage", () => {
|
||||
it("403 for member WITHOUT bot.manage on POST /api/bot", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app)
|
||||
.post("/api/bot")
|
||||
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => {
|
||||
const app = makeApp(member(["bot.manage"], "all"));
|
||||
const res = await request(app)
|
||||
.post("/api/bot")
|
||||
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => {
|
||||
const app = makeApp(member(["bot.manage"], ["other-bot"]));
|
||||
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => {
|
||||
const app = makeApp(member(["bot.manage"], [ALLOWED_BOT]));
|
||||
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform.auth", () => {
|
||||
it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => {
|
||||
const app = makeApp(member(["platform.auth"], "all"));
|
||||
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quality", () => {
|
||||
it("403 for member WITHOUT quality on POST /api/music/quality", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for member WITH quality on POST /api/music/quality", async () => {
|
||||
const app = makeApp(member(["quality"], "all"));
|
||||
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only routes stay open", () => {
|
||||
it("GET /api/auth/status not gated", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).get("/api/auth/status");
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("GET /api/music/quality not gated", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).get("/api/music/quality");
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("GET /api/bot not gated", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).get("/api/bot");
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin bypasses every gate", () => {
|
||||
let app: express.Express;
|
||||
beforeEach(() => { app = makeApp(admin); });
|
||||
|
||||
it("player.control", async () => {
|
||||
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
||||
});
|
||||
it("player.queue", async () => {
|
||||
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
||||
});
|
||||
it("bot.manage POST /api/bot", async () => {
|
||||
const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
it("bot.manage POST /api/bot/:id/start", async () => {
|
||||
expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403);
|
||||
});
|
||||
it("platform.auth POST /api/auth/cookie", async () => {
|
||||
expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403);
|
||||
});
|
||||
it("quality POST /api/music/quality", async () => {
|
||||
expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-22
@@ -4,6 +4,7 @@ import type { BotDatabase } from "../../data/database.js";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { parseCommand } from "../../bot/commands.js";
|
||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||
|
||||
export function createPlayerRouter(
|
||||
botManager: BotManager,
|
||||
@@ -15,6 +16,13 @@ export function createPlayerRouter(
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Access check runs BEFORE the existence/resolver check so a member who is
|
||||
// not allowed a bot always gets a uniform 403 — whether or not the bot
|
||||
// exists — instead of a 404 that would leak which bot IDs are real.
|
||||
// requireBotAccess only needs req.params.botId and req.user (set by the
|
||||
// global requireAuth mounted earlier), so it works before the resolver.
|
||||
router.use("/:botId", requireBotAccess("botId"));
|
||||
|
||||
router.use("/:botId", (req, res, next) => {
|
||||
const bot = botManager.getBot(req.params.botId);
|
||||
if (!bot) {
|
||||
@@ -33,7 +41,7 @@ export function createPlayerRouter(
|
||||
return "";
|
||||
};
|
||||
|
||||
router.post("/:botId/play", async (req, res) => {
|
||||
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { query, platform } = req.body;
|
||||
@@ -53,7 +61,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/add", async (req, res) => {
|
||||
router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { query, platform } = req.body;
|
||||
@@ -80,14 +88,14 @@ export function createPlayerRouter(
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/:botId/pause", simpleCommand("!pause"));
|
||||
router.post("/:botId/resume", simpleCommand("!resume"));
|
||||
router.post("/:botId/next", simpleCommand("!next"));
|
||||
router.post("/:botId/prev", simpleCommand("!prev"));
|
||||
router.post("/:botId/stop", simpleCommand("!stop"));
|
||||
router.post("/:botId/clear", simpleCommand("!clear"));
|
||||
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
|
||||
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
|
||||
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
|
||||
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
|
||||
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
||||
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
||||
|
||||
router.post("/:botId/fm", async (req, res) => {
|
||||
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { platform } = req.body;
|
||||
@@ -109,7 +117,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/volume", async (req, res) => {
|
||||
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { volume } = req.body;
|
||||
@@ -137,7 +145,7 @@ export function createPlayerRouter(
|
||||
|
||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||
|
||||
router.post("/:botId/mode", async (req, res) => {
|
||||
router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { mode } = req.body;
|
||||
@@ -162,7 +170,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Seek to position
|
||||
router.post("/:botId/seek", async (req, res) => {
|
||||
router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { position } = req.body; // seconds
|
||||
@@ -186,7 +194,7 @@ export function createPlayerRouter(
|
||||
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
||||
});
|
||||
|
||||
router.delete("/:botId/queue/:index", async (req, res) => {
|
||||
router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
||||
@@ -198,7 +206,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Jump to a specific index in the queue (without clearing it)
|
||||
router.post("/:botId/play-at", async (req, res) => {
|
||||
router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { index } = req.body;
|
||||
@@ -232,7 +240,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/playlist", async (req, res) => {
|
||||
router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
@@ -249,7 +257,7 @@ export function createPlayerRouter(
|
||||
|
||||
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||
// Respects current play mode (random = pick random first song)
|
||||
router.post("/:botId/play-playlist", async (req, res) => {
|
||||
router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
@@ -336,7 +344,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||
router.post("/:botId/play-album", async (req, res) => {
|
||||
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { albumId, platform } = req.body;
|
||||
@@ -408,7 +416,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Play a single song by ID — resolves URL on demand
|
||||
router.post("/:botId/play-song", async (req, res) => {
|
||||
router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -436,7 +444,7 @@ export function createPlayerRouter(
|
||||
|
||||
// Insert a single song to play right after the current one.
|
||||
// If nothing is playing, behaves like /play-song (start immediately).
|
||||
router.post("/:botId/play-next-song", async (req, res) => {
|
||||
router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -474,7 +482,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/add-song", async (req, res) => {
|
||||
router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -502,7 +510,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Add a song to queue by ID — metadata only
|
||||
router.post("/:botId/add-by-id", async (req, res) => {
|
||||
router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
@@ -540,7 +548,7 @@ export function createPlayerRouter(
|
||||
res.json(bot.getProfileManager().getConfig());
|
||||
});
|
||||
|
||||
router.put("/:botId/profile", (req, res) => {
|
||||
router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const pm = bot.getProfileManager();
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { createSessionRouter } from "./session.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
@@ -15,7 +16,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
const audit = createAuditStore(botDb.db);
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -100,6 +102,12 @@ describe("session router", () => {
|
||||
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.username).toBe("alice");
|
||||
// alice is the first user (an admin), so /me exposes all capabilities and full bot access.
|
||||
expect(Array.isArray(me.body.capabilities)).toBe(true);
|
||||
expect(me.body.capabilities).toEqual(
|
||||
expect.arrayContaining(["player.control", "player.queue", "bot.manage", "platform.auth", "quality"])
|
||||
);
|
||||
expect(me.body.bots).toBe("all");
|
||||
|
||||
const anon = await request(app).get("/api/session/me");
|
||||
expect(anon.status).toBe(401);
|
||||
|
||||
+12
-2
@@ -4,6 +4,7 @@ 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 { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
|
||||
|
||||
@@ -49,7 +50,8 @@ export function createSessionRouter(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
permissions: PermissionStore
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -133,7 +135,15 @@ export function createSessionRouter(
|
||||
});
|
||||
|
||||
router.get("/me", requireAuthInline, (req, res) => {
|
||||
res.json(req.user);
|
||||
const user = req.user!;
|
||||
const ctx = resolvePermissionContext(user.role, user.id, permissions);
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
capabilities: [...ctx.capabilities],
|
||||
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/change-password", requireAuthInline, async (req, res) => {
|
||||
|
||||
@@ -6,7 +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 { 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";
|
||||
@@ -15,11 +16,12 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
const requireAuth = createRequireAuth(sessions);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
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", () => {
|
||||
@@ -27,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;
|
||||
@@ -35,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}`;
|
||||
@@ -148,10 +152,10 @@ describe("users router", () => {
|
||||
const localApp = express();
|
||||
localApp.use(express.json());
|
||||
localApp.use(cookieParser());
|
||||
localApp.use("/api", createRequireAuth(sessions));
|
||||
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")
|
||||
@@ -254,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: [] });
|
||||
});
|
||||
});
|
||||
+44
-1
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "./requireAuth.js";
|
||||
import { requireAdmin } from "./requireAdmin.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -19,13 +20,14 @@ describe("requireAdmin middleware", () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const permissions = createPermissionStore(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(createRequireAuth(sessions, permissions));
|
||||
app.use(requireAdmin);
|
||||
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "./requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
@@ -17,12 +18,13 @@ describe("requireAuth middleware", () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
validToken = sessions.createSession(u.id).token;
|
||||
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(createRequireAuth(sessions));
|
||||
app.use(createRequireAuth(sessions, permissions));
|
||||
app.get("/protected", (req, res) => {
|
||||
res.json({ ok: true, user: (req as any).user });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||
import {
|
||||
validateSessionFromHeaders,
|
||||
extractSessionToken,
|
||||
@@ -9,11 +10,17 @@ 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<string>;
|
||||
bots?: "all" | Set<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequireAuth(sessions: SessionStore): RequestHandler {
|
||||
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
|
||||
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
@@ -21,7 +28,14 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler {
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = { id: result.userId, username: result.username, role: result.role };
|
||||
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
|
||||
req.user = {
|
||||
id: result.userId,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
capabilities: ctx.capabilities,
|
||||
bots: ctx.bots,
|
||||
};
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
});
|
||||
it("401 when unauthenticated", async () => {
|
||||
const app = express();
|
||||
app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true }));
|
||||
expect((await request(app).post("/bot/b1")).status).toBe(401);
|
||||
});
|
||||
it("403 when the route param is absent", async () => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); });
|
||||
app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true }));
|
||||
expect((await request(app).post("/bot/b1")).status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
|
||||
// Generic over the route-param shape (`P`) so Express can keep inferring
|
||||
// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when
|
||||
// these are passed as a per-route middleware argument. Pinning the default
|
||||
// `ParamsDictionary` here would otherwise force the broad
|
||||
// `string | string[]` param overload on every route they guard.
|
||||
export function requirePermission<P = Record<string, string>>(capability: string): RequestHandler<P> {
|
||||
return (req: Request<P>, 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<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
|
||||
return (req: Request<P>, 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 as Record<string, string | undefined>)[paramName];
|
||||
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
};
|
||||
}
|
||||
+5
-3
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
|
||||
import { setupWebSocket } from "./websocket.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||
import { csrfOriginCheck } from "./middleware/csrf.js";
|
||||
@@ -74,6 +75,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);
|
||||
const permissions = createPermissionStore(options.database.db);
|
||||
|
||||
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||
app.get("/api/health", (_req, res) => {
|
||||
@@ -93,10 +95,10 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.use("/api/session/login", loginLimit);
|
||||
app.use("/api/session/setup", setupLimit);
|
||||
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions));
|
||||
|
||||
// ─── Gates for everything else under /api ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions);
|
||||
const requireAuth = createRequireAuth(sessions, permissions);
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
@@ -127,7 +129,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
|
||||
|
||||
// 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) ────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user