fix(housekeeping): close final authorization gaps
This commit is contained in:
1 parent
2b8f73a91d
commit
222535e116
9 files changed
+219
-44
No files matched your search
+95
-2
@@ -5,6 +5,7 @@ import type { HousekeepingCapabilityContext } from "../../../foundation/contract
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
hashPassword: vi.fn(),
|
||||
invalidateLoginCache: vi.fn(),
|
||||
insertValues: vi.fn(),
|
||||
@@ -56,6 +57,7 @@ function selectResult() {
|
||||
|
||||
function databaseFacade() {
|
||||
const facade = {
|
||||
execute: mocks.execute,
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(selectResult),
|
||||
@@ -164,14 +166,98 @@ beforeEach(() => {
|
||||
mocks.selectQueue.length = 0;
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.execute.mockResolvedValue([[{ id: 8 }], []]);
|
||||
mocks.hashPassword.mockResolvedValue("hashed-password");
|
||||
mocks.reloadSettings.mockResolvedValue(undefined);
|
||||
for (const call of Object.values(mocks.rcon)) call.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("People production workflow adapter", () => {
|
||||
it("does not treat a high numeric rank as a super-admin hierarchy bypass", async () => {
|
||||
mocks.resolveServerContext.mockResolvedValue({
|
||||
...context(),
|
||||
actor: { id: 42, username: "operator", rank: 8 },
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
mocks.selectQueue.push([target({ rank: 8 })]);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Denied" } },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents a non-super-admin from assigning their own configured rank", async () => {
|
||||
mocks.resolveServerContext.mockResolvedValue({
|
||||
...context(),
|
||||
actor: { id: 42, username: "operator", rank: 8 },
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
mocks.selectQueue.push([target({ rank: 2 })]);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 8 } },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a rank assignment when the configured rank does not exist", async () => {
|
||||
mocks.resolveServerContext.mockResolvedValue({
|
||||
...context(),
|
||||
actor: { id: 42, username: "operator", rank: 8 },
|
||||
isSuperAdmin: true,
|
||||
});
|
||||
mocks.selectQueue.push([target({ rank: 2 })]);
|
||||
mocks.execute.mockResolvedValueOnce([[], []]);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 8 } },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { rank: ["errors.validation.invalid"] },
|
||||
},
|
||||
});
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks every bulk target hierarchy before starting a mutation", async () => {
|
||||
mocks.resolveServerContext.mockResolvedValue({
|
||||
...context(),
|
||||
actor: { id: 42, username: "operator", rank: 8 },
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
mocks.selectQueue.push([{ id: 7, rank: 8 }]);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"users.bulk-ban",
|
||||
{ userIds: [7], duration: 3600, reason: "Denied" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves legacy bulk order, duplicates, totals, and StaffActivities", async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, index) => (index % 2) + 1);
|
||||
mocks.selectQueue.push([
|
||||
{ id: 1, rank: 2 },
|
||||
{ id: 2, rank: 2 },
|
||||
]);
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"users.bulk-unban",
|
||||
@@ -694,6 +780,10 @@ describe("People production workflow adapter", () => {
|
||||
);
|
||||
|
||||
it("executes bulk ban, currency, and badge with database, RCON, and legacy activity outcomes", async () => {
|
||||
mocks.selectQueue.push([
|
||||
{ id: 7, rank: 2 },
|
||||
{ id: 8, rank: 2 },
|
||||
]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-ban", {
|
||||
userIds: [7, 8],
|
||||
@@ -706,6 +796,7 @@ describe("People production workflow adapter", () => {
|
||||
});
|
||||
expect(mocks.insertValues).toHaveBeenCalledTimes(2);
|
||||
|
||||
mocks.selectQueue.push([{ id: 7, rank: 2 }]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-currency", {
|
||||
userIds: [7],
|
||||
@@ -718,7 +809,7 @@ describe("People production workflow adapter", () => {
|
||||
});
|
||||
expect(mocks.rcon.giveCredits).toHaveBeenCalledWith(7, 50);
|
||||
|
||||
mocks.selectQueue.push([], [{ maxSlot: 2 }]);
|
||||
mocks.selectQueue.push([{ id: 7, rank: 2 }], [], [{ maxSlot: 2 }]);
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-badge", {
|
||||
userIds: [7],
|
||||
@@ -743,6 +834,7 @@ describe("People production workflow adapter", () => {
|
||||
it.each(["false", "throw"] as const)(
|
||||
"reports bulk currency database completion separately when RCON reports %s",
|
||||
async (failureMode) => {
|
||||
mocks.selectQueue.push([{ id: 7, rank: 2 }]);
|
||||
if (failureMode === "false") {
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
} else {
|
||||
@@ -790,7 +882,7 @@ describe("People production workflow adapter", () => {
|
||||
it.each(["false", "throw"] as const)(
|
||||
"reports bulk badge database completion separately when RCON reports %s",
|
||||
async (failureMode) => {
|
||||
mocks.selectQueue.push([], [{ maxSlot: 2 }]);
|
||||
mocks.selectQueue.push([{ id: 7, rank: 2 }], [], [{ maxSlot: 2 }]);
|
||||
if (failureMode === "false") {
|
||||
mocks.rcon.giveBadge.mockResolvedValue(false);
|
||||
} else {
|
||||
@@ -895,6 +987,7 @@ describe("People production workflow adapter", () => {
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
mocks.selectQueue.push([{ id: 7, rank: 2 }]);
|
||||
const legacyBulk = await peopleMutationService.execute(
|
||||
invocation,
|
||||
"users.bulk-currency",
|
||||
|
||||
@@ -149,6 +149,8 @@ function operationCapability(operation: PeopleMutationOperation) {
|
||||
if (
|
||||
operation === "user.ban" ||
|
||||
operation === "user.unban" ||
|
||||
operation === "users.bulk-ban" ||
|
||||
operation === "users.bulk-unban" ||
|
||||
operation === "ban.create" ||
|
||||
operation === "ban.lift" ||
|
||||
operation === "help-ticket.unban"
|
||||
@@ -400,7 +402,7 @@ async function loadTarget(
|
||||
if (
|
||||
guardHierarchy &&
|
||||
target.rank >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
!context.capability.isSuperAdmin
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
@@ -410,6 +412,43 @@ async function loadTarget(
|
||||
return target;
|
||||
}
|
||||
|
||||
async function assertBulkTargetHierarchy(
|
||||
userIds: readonly number[],
|
||||
context: PeopleMutationContext,
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(userIds)];
|
||||
const targets = await db
|
||||
.select({ id: User.id, rank: User.rank })
|
||||
.from(User)
|
||||
.where(inArray(User.id, uniqueIds));
|
||||
if (targets.length !== uniqueIds.length) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!context.capability.isSuperAdmin &&
|
||||
targets.some((target) => target.rank >= context.capability.actor.rank)
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertConfiguredRankExists(rank: number): Promise<void> {
|
||||
const [rows] = (await db.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
|
||||
)) as unknown as [unknown[], unknown];
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new PeopleMutationFailure("VALIDATION", "errors.validation.invalid", {
|
||||
rank: ["errors.validation.invalid"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function targetSnapshot(target: TargetUser) {
|
||||
return {
|
||||
id: target.id,
|
||||
@@ -592,15 +631,18 @@ async function executeUserMutation(
|
||||
if (operation === "user.update") {
|
||||
const fields = record(data.fields);
|
||||
const nextRank = fields.rank;
|
||||
if (
|
||||
nextRank !== undefined &&
|
||||
positiveInteger(nextRank) >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
if (nextRank !== undefined) {
|
||||
const rank = positiveInteger(nextRank);
|
||||
if (
|
||||
rank >= context.capability.actor.rank &&
|
||||
!context.capability.isSuperAdmin
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
await assertConfiguredRankExists(rank);
|
||||
}
|
||||
const userPatch = Object.fromEntries(
|
||||
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
|
||||
@@ -1072,6 +1114,7 @@ async function executeBulkMutation(
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ids = userIds(data.userIds, context.legacy);
|
||||
await assertBulkTargetHierarchy(ids, context);
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
const externalSyncFailures: Array<{ userId: number; reason: string }> = [];
|
||||
const externalSyncFailureReason =
|
||||
|
||||
Reference in new issue
Block a user