fix: authorize super admins by dynamic highest rank
Remote Build and Deploy / deploy (push) Successful in 42s

This commit is contained in:
Simo committed 2026-07-11 22:35:40 +02:00
1 parent bb847176ad
commit f08e56cf53
10 files changed
+173 -65

No files matched your search

@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("authorization source contract", () => {
it("contains no fixed numeric rank threshold in central authorization files", () => {
for (const file of ["src/lib/permissions.ts", "src/lib/proxy-access.ts", "src/lib/admin/guard.ts"]) {
const source = readFileSync(file, "utf8");
expect(source, file).not.toMatch(/rank\s*[<>]=?\s*\d+/i);
}
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { decideAuthorization, isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
describe("isDynamicSuperAdmin", () => {
it.each([[7, 7], [11, 11], [2000, 2000]])("accepts highest rank %i", (rank, highest) => {
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
});
it("demotes the previous highest rank", () => expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
it("fails closed without ranks", () => expect(isDynamicSuperAdmin(1, null)).toBe(false));
});
describe("decideAuthorization", () => {
const actor = { id: 1, username: "admin", rank: 11 };
it("allows the dynamically highest rank", () => expect(decideAuthorization({ actor, highestRank: 11, permission: "admin.any", hasPermission: false }).allowed).toBe(true));
it("allows explicit ACL permission below highest", () => expect(decideAuthorization({ actor, highestRank: 12, permission: "admin.news.view", hasPermission: true }).allowed).toBe(true));
it("denies invalid ranks", () => expect(decideAuthorization({ actor: { ...actor, rank: 0 }, highestRank: 11, permission: "admin.any", hasPermission: true })).toMatchObject({ allowed: false, reason: "invalid_rank" }));
it("denies missing permission", () => expect(decideAuthorization({ actor, highestRank: 12, permission: "admin.any", hasPermission: false })).toMatchObject({ allowed: false, reason: "permission_denied" }));
});
+22
View File
@@ -0,0 +1,22 @@
export interface AuthorizationActor { id: number; username: string; rank: number }
export type AuthorizationDenialReason = "invalid_rank" | "permission_denied" | "no_ranks";
export type AuthorizationDecision =
| { allowed: true; superAdmin: boolean }
| { allowed: false; reason: AuthorizationDenialReason };
export function isDynamicSuperAdmin(rank: number, highestRank: number | null): boolean {
return Number.isInteger(rank) && rank > 0 && highestRank !== null && rank === highestRank;
}
export function decideAuthorization(input: {
actor: AuthorizationActor;
highestRank: number | null;
permission?: string;
hasPermission: boolean;
}): AuthorizationDecision {
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0) return { allowed: false, reason: "invalid_rank" };
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank)) return { allowed: true, superAdmin: true };
if (!input.permission || input.hasPermission) return { allowed: true, superAdmin: false };
return { allowed: false, reason: "permission_denied" };
}
+4 -20
View File
@@ -1,18 +1,10 @@
import { redirect } from "next/navigation";
import { isStaff } from "@/lib/admin/is-staff";
import { resolveStaffUser } from "@/lib/admin/staff-user";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { clientIp, rateLimit } from "@/lib/rate-limit";
export { isStaff };
export async function getMinStaffRank(): Promise<number> {
const n = Number(await siteSettings.get("min_staff_rank", "7"));
return Number.isFinite(n) ? n : 7;
}
export interface StaffUser {
id: number;
rank: number;
@@ -24,17 +16,9 @@ export interface StaffUser {
* and to / when authenticated but not staff. Returns the staff user otherwise.
*/
export async function requireStaff(): Promise<StaffUser> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const minRank = await getMinStaffRank();
const staff = await resolveStaffUser(session.user.id, minRank, (id) =>
prisma.user.findUnique({
where: { id },
select: { id: true, rank: true, username: true },
}),
);
if (!staff) redirect("/");
return staff;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirect("/");
return { id: session.user.id, rank: session.user.rank, username: session.user.username };
}
/**
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveAuthorizationState } from "@/lib/admin/rank-authority";
function db(user: { id: number; username: string; rank: number } | null, highest: number | null) {
return {
user: { findUnique: async () => user },
highestRank: async () => highest,
};
}
describe("resolveAuthorizationState", () => {
it("uses current database rank and highest rank 2000", async () => {
await expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 2000 }, 2000))).resolves.toEqual({ actor: { id: 7, username: "root", rank: 2000 }, highestRank: 2000 });
});
it("returns null for a deleted user", async () => expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
it("fails closed when there are no ranks", async () => expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 1 }, null))).resolves.toEqual({ actor: { id: 7, username: "root", rank: 1 }, highestRank: null }));
});
+15
View File
@@ -0,0 +1,15 @@
import type { AuthorizationActor } from "@/lib/admin/authorization-policy";
export interface RankAuthorityDb {
user: { findUnique(args: { where: { id: number }; select: { id: true; username: true; rank: true } }): Promise<{ id: number; username: string; rank: number } | null> };
highestRank(): Promise<number | null>;
}
export async function resolveAuthorizationState(userId: number, db: RankAuthorityDb): Promise<{ actor: AuthorizationActor; highestRank: number | null } | null> {
const [user, highestRank] = await Promise.all([
db.user.findUnique({ where: { id: userId }, select: { id: true, username: true, rank: true } }),
db.highestRank(),
]);
if (!user) return null;
return { actor: user, highestRank };
}