mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(auth): atomic last-admin guards on role-change and delete
This commit is contained in:
1 parent
c0504d65a5
commit
1a11489f2e
3 files changed
+120
-25
No files matched your search
@@ -122,4 +122,65 @@ describe("UserStore", () => {
|
|||||||
expect(alice.role).toBe("admin");
|
expect(alice.role).toBe("admin");
|
||||||
expect(bob.role).toBe("member");
|
expect(bob.role).toBe("member");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
|
||||||
|
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
await users.createUser("bob", "pw-bob-bob", "admin");
|
||||||
|
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
|
||||||
|
expect(users.findById(alice.id)!.role).toBe("member");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
|
||||||
|
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||||
|
// Concurrent demotion of both
|
||||||
|
const [r1, r2] = await Promise.all([
|
||||||
|
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
|
||||||
|
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
|
||||||
|
]);
|
||||||
|
// Exactly one should succeed; the other gets "would_orphan"
|
||||||
|
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||||
|
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||||
|
expect(oks).toBe(1);
|
||||||
|
expect(orphans).toBe(1);
|
||||||
|
// System retains at least one admin
|
||||||
|
expect(users.countAdmins()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
|
||||||
|
expect(users.findById(alice.id)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
|
||||||
|
await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
||||||
|
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
|
||||||
|
expect(users.findById(bob.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||||
|
const [r1, r2] = await Promise.all([
|
||||||
|
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
|
||||||
|
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
|
||||||
|
]);
|
||||||
|
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||||
|
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||||
|
expect(oks).toBe(1);
|
||||||
|
expect(orphans).toBe(1);
|
||||||
|
expect(users.countAdmins()).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -25,7 +25,9 @@ export interface UserStore {
|
|||||||
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
||||||
changePassword(userId: string, newPassword: string): Promise<void>;
|
changePassword(userId: string, newPassword: string): Promise<void>;
|
||||||
setRole(userId: string, role: UserRole): boolean;
|
setRole(userId: string, role: UserRole): boolean;
|
||||||
|
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
|
||||||
deleteUser(id: string): boolean;
|
deleteUser(id: string): boolean;
|
||||||
|
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
|
||||||
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +127,21 @@ export function createUserStore(db: Database.Database): UserStore {
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setRoleIfNotLastAdmin(id, newRole) {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||||
|
if (!row) return "not_found" as const;
|
||||||
|
if (row.role === newRole) return "ok" as const; // no-op
|
||||||
|
if (row.role === "admin" && newRole === "member") {
|
||||||
|
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||||
|
if (adminCount <= 1) return "would_orphan" as const;
|
||||||
|
}
|
||||||
|
updateRoleStmt.run(newRole, Date.now(), id);
|
||||||
|
return "ok" as const;
|
||||||
|
});
|
||||||
|
return tx();
|
||||||
|
},
|
||||||
|
|
||||||
listUsers() {
|
listUsers() {
|
||||||
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||||
},
|
},
|
||||||
@@ -133,5 +150,19 @@ export function createUserStore(db: Database.Database): UserStore {
|
|||||||
const result = deleteUserStmt.run(id);
|
const result = deleteUserStmt.run(id);
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteUserIfNotLastAdmin(id) {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||||
|
if (!row) return "not_found" as const;
|
||||||
|
if (row.role === "admin") {
|
||||||
|
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||||
|
if (adminCount <= 1) return "would_orphan" as const;
|
||||||
|
}
|
||||||
|
deleteUserStmt.run(id);
|
||||||
|
return "ok" as const;
|
||||||
|
});
|
||||||
|
return tx();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+28
-25
@@ -58,6 +58,7 @@ export function createUsersRouter(
|
|||||||
|
|
||||||
router.delete("/:id", (req, res) => {
|
router.delete("/:id", (req, res) => {
|
||||||
const targetId = req.params.id;
|
const targetId = req.params.id;
|
||||||
|
// Snapshot target's username BEFORE deletion for audit
|
||||||
const target = users.findById(targetId);
|
const target = users.findById(targetId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
res.status(404).json({ error: "not found" });
|
res.status(404).json({ error: "not found" });
|
||||||
@@ -67,15 +68,16 @@ export function createUsersRouter(
|
|||||||
res.status(400).json({ error: "cannot delete self" });
|
res.status(400).json({ error: "cannot delete self" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.role === "admin" && users.countAdmins() <= 1) {
|
const result = users.deleteUserIfNotLastAdmin(targetId);
|
||||||
res.status(400).json({ error: "cannot delete last admin" });
|
if (result === "not_found") {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deleted = users.deleteUser(targetId);
|
|
||||||
if (!deleted) {
|
|
||||||
res.status(404).json({ error: "not found" });
|
res.status(404).json({ error: "not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result === "would_orphan") {
|
||||||
|
res.status(400).json({ error: "cannot delete last admin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
||||||
sessions.deleteAllForUser(targetId);
|
sessions.deleteAllForUser(targetId);
|
||||||
try {
|
try {
|
||||||
audit.record({
|
audit.record({
|
||||||
@@ -128,34 +130,35 @@ export function createUsersRouter(
|
|||||||
res.status(400).json({ error: "invalid role" });
|
res.status(400).json({ error: "invalid role" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = users.findById(targetId);
|
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
|
||||||
if (!target) {
|
// so we record what actually changed; if the user is gone we'll skip audit).
|
||||||
|
const targetBefore = users.findById(targetId);
|
||||||
|
if (!targetBefore) {
|
||||||
res.status(404).json({ error: "not found" });
|
res.status(404).json({ error: "not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.role === newRole) {
|
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
|
||||||
res.status(204).end();
|
if (result === "not_found") {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.role === "admin" && newRole === "member" && users.countAdmins() <= 1) {
|
if (result === "would_orphan") {
|
||||||
res.status(400).json({ error: "cannot demote last admin" });
|
res.status(400).json({ error: "cannot demote last admin" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const changed = users.setRole(targetId, newRole);
|
// Only audit when the role actually changed
|
||||||
if (!changed) {
|
if (targetBefore.role !== newRole) {
|
||||||
res.status(404).json({ error: "not found" });
|
try {
|
||||||
return;
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
|
||||||
|
action: "user.role_changed",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
audit.record({
|
|
||||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
|
||||||
targetUserId: target.id, targetUsername: target.username,
|
|
||||||
action: "user.role_changed",
|
|
||||||
});
|
|
||||||
} catch (auditErr) {
|
|
||||||
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
|
||||||
}
|
|
||||||
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user