mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat(perm): admin permissions API + audit + new-member basic tier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1f0f162f66
commit
ce15f36e5e
4 files changed
+138
-9
No files matched your search
+2
-1
@@ -6,7 +6,8 @@ export type AuditAction =
|
|||||||
| "user.deleted"
|
| "user.deleted"
|
||||||
| "user.password_reset"
|
| "user.password_reset"
|
||||||
| "user.password_changed"
|
| "user.password_changed"
|
||||||
| "user.role_changed";
|
| "user.role_changed"
|
||||||
|
| "user.permissions_changed";
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import pino from "pino";
|
|||||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||||
import { createSessionStore, type SessionStore } from "../../data/sessions.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 } from "../../data/permissions.js";
|
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
|
||||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
import { createUsersRouter } from "./users.js";
|
import { createUsersRouter } from "./users.js";
|
||||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.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 requireAuth = createRequireAuth(sessions, permissions);
|
||||||
const audit = createAuditStore(botDb.db);
|
const audit = createAuditStore(botDb.db);
|
||||||
app.use("/api", requireAuth);
|
app.use("/api", requireAuth);
|
||||||
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" })));
|
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||||
return app;
|
return { app, permissions, audit };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("users router", () => {
|
describe("users router", () => {
|
||||||
@@ -29,6 +29,8 @@ describe("users router", () => {
|
|||||||
let users: UserStore;
|
let users: UserStore;
|
||||||
let sessions: SessionStore;
|
let sessions: SessionStore;
|
||||||
let app: express.Express;
|
let app: express.Express;
|
||||||
|
let permissions: PermissionStore;
|
||||||
|
let audit: AuditStore;
|
||||||
let aliceId: string;
|
let aliceId: string;
|
||||||
let aliceCookie: string;
|
let aliceCookie: string;
|
||||||
let bobId: string;
|
let bobId: string;
|
||||||
@@ -37,7 +39,7 @@ describe("users router", () => {
|
|||||||
botDb = createDatabase(":memory:");
|
botDb = createDatabase(":memory:");
|
||||||
users = createUserStore(botDb.db);
|
users = createUserStore(botDb.db);
|
||||||
sessions = createSessionStore(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");
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
aliceId = alice.id;
|
aliceId = alice.id;
|
||||||
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
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", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||||
localApp.use(
|
localApp.use(
|
||||||
"/api/users",
|
"/api/users",
|
||||||
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }))
|
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
|
||||||
);
|
);
|
||||||
const res = await request(localApp)
|
const res = await request(localApp)
|
||||||
.post("/api/users")
|
.post("/api/users")
|
||||||
@@ -256,4 +258,87 @@ describe("users router", () => {
|
|||||||
.send({ role: "admin" });
|
.send({ role: "admin" });
|
||||||
expect(res.status).toBe(404);
|
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 { UsernameTakenError } from "../../data/users.js";
|
||||||
import type { SessionStore } from "../../data/sessions.js";
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
import type { AuditStore } from "../../data/audit.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";
|
import { extractSessionToken } from "../auth/validateSession.js";
|
||||||
|
|
||||||
function isValidUsername(v: unknown): v is string {
|
function isValidUsername(v: unknown): v is string {
|
||||||
@@ -18,7 +19,8 @@ export function createUsersRouter(
|
|||||||
users: UserStore,
|
users: UserStore,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
audit: AuditStore,
|
audit: AuditStore,
|
||||||
logger: Logger
|
logger: Logger,
|
||||||
|
permissions: PermissionStore
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ export function createUsersRouter(
|
|||||||
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
||||||
try {
|
try {
|
||||||
const u = await users.createUser(username, password, role);
|
const u = await users.createUser(username, password, role);
|
||||||
|
if (u.role === "member") {
|
||||||
|
permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
audit.record({
|
audit.record({
|
||||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
@@ -162,5 +167,43 @@ export function createUsersRouter(
|
|||||||
res.status(204).end();
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -126,7 +126,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||||
);
|
);
|
||||||
// admin-only routes
|
// 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));
|
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||||
|
|
||||||
// ─── Static SPA (public) ────────────────────────────────────────────────
|
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user