fix: authorize super admins by dynamic highest rank
Remote Build and Deploy / deploy (push) Successful in 42s
Remote Build and Deploy / deploy (push) Successful in 42s
This commit is contained in:
1 parent
bb847176ad
commit
f08e56cf53
10 files changed
+173
-65
No files matched your search
@@ -0,0 +1,37 @@
|
||||
-- Normalize ACL discriminator casing and ensure every emulator rank has a CMS role.
|
||||
UPDATE `acl_model_permissions` SET `model_type` = 'Role' WHERE LOWER(`model_type`) = 'role';
|
||||
UPDATE `acl_model_roles` SET `model_type` = 'User' WHERE LOWER(`model_type`) = 'user';
|
||||
|
||||
INSERT INTO `acl_roles` (`slug`, `title`, `description`)
|
||||
SELECT CONCAT('rank_', pr.id), COALESCE(NULLIF(pr.rank_name, ''), CONCAT('Rank ', pr.id)), 'CMS role synchronized from permission_ranks'
|
||||
FROM `permission_ranks` pr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `acl_roles` ar WHERE ar.slug = CONCAT('rank_', pr.id));
|
||||
|
||||
-- Preserve the former moderator fallback as explicit ACL data.
|
||||
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM `permission_ranks` pr
|
||||
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN `acl_permissions` ap ON (
|
||||
pr.id >= 3 AND (
|
||||
ap.slug = 'mod.dashboard' OR ap.slug = 'mod.actions' OR
|
||||
(ap.slug LIKE 'mod.%' AND ap.slug LIKE '%.view')
|
||||
)
|
||||
)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `acl_model_permissions` amp
|
||||
WHERE amp.model_type = 'Role' AND amp.model_id = ar.id AND amp.permission_id = ap.id
|
||||
);
|
||||
|
||||
-- Preserve the former administrator read-only fallback as explicit ACL data.
|
||||
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM `permission_ranks` pr
|
||||
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN `acl_permissions` ap ON (
|
||||
pr.id >= 6 AND (ap.slug = 'admin.dashboard' OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view'))
|
||||
)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `acl_model_permissions` amp
|
||||
WHERE amp.model_type = 'Role' AND amp.model_id = ar.id AND amp.permission_id = ap.id
|
||||
);
|
||||
@@ -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" }));
|
||||
});
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
+44
-39
@@ -5,6 +5,8 @@ import { auth } from './auth'
|
||||
import { sessionUserId } from './auth/session-user'
|
||||
import { prisma } from './prisma'
|
||||
import { logAuthorizationEvent } from './admin/authorization-events'
|
||||
import { isDynamicSuperAdmin } from './admin/authorization-policy'
|
||||
import { resolveAuthorizationState } from './admin/rank-authority'
|
||||
|
||||
// Re-export PERMS from the standalone file (safe for client components)
|
||||
export { PERMS } from './permission-slugs'
|
||||
@@ -54,17 +56,18 @@ const getCachedPermissionSlugs = unstable_cache(
|
||||
)
|
||||
|
||||
/**
|
||||
* Load permission slugs for a user. Rank is taken from the JWT session
|
||||
* to avoid an extra DB query. Super admin (rank >= 7) bypasses all checks.
|
||||
* Load permission slugs for a database-refreshed user rank. The dynamically
|
||||
* highest rank bypasses ACL checks.
|
||||
* Wrapped with React cache() to de-duplicate within the same request.
|
||||
*/
|
||||
export const loadUserPermissions = cache(async function loadUserPermissions(
|
||||
userId: number,
|
||||
rank: number,
|
||||
highestRank: number | null,
|
||||
): Promise<PermissionSet> {
|
||||
try {
|
||||
// Super admin bypasses all permission checks — zero DB queries
|
||||
if (rank >= 7) {
|
||||
if (isDynamicSuperAdmin(rank, highestRank)) {
|
||||
return {
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
@@ -90,6 +93,18 @@ export const loadUserPermissions = cache(async function loadUserPermissions(
|
||||
}
|
||||
})
|
||||
|
||||
const getCurrentAuthorizationState = cache(async (userId: number) =>
|
||||
resolveAuthorizationState(userId, {
|
||||
user: prisma.user,
|
||||
highestRank: async () => {
|
||||
const rows = await prisma.$queryRaw<{ highest_rank: number | bigint | null }[]>`
|
||||
SELECT MAX(id) AS highest_rank FROM permission_ranks
|
||||
`
|
||||
return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// ── Context Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -104,8 +119,10 @@ export async function getAdminContext() {
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) redirect('/login')
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
const state = await getCurrentAuthorizationState(userId)
|
||||
if (!state) redirect('/login')
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,38 +135,23 @@ export async function getApiAdminContext() {
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) return null
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
const state = await getCurrentAuthorizationState(userId)
|
||||
if (!state) return null
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission.
|
||||
* Super admins (rank >= 7) bypass all checks via PermissionSet.isSuperAdmin.
|
||||
* Rank >= 6 gets fallback access to admin dashboard and admin .view permissions.
|
||||
* Rank >= 3 gets fallback access to mod dashboard and mod .view permissions.
|
||||
* All fallbacks are represented as ACL role permissions by migration 0011.
|
||||
*/
|
||||
export function canAccess(permissions: PermissionSet, slug: string, rank: number): boolean {
|
||||
if (permissions.has(slug)) return true
|
||||
// Rank 6+ fallback: admin dashboard and admin view-only permissions
|
||||
if (
|
||||
rank >= 6 &&
|
||||
(slug === PERMS.ADMIN_DASHBOARD || (slug.startsWith('admin.') && slug.endsWith('.view')))
|
||||
)
|
||||
return true
|
||||
// Rank 3+ fallback: mod dashboard and mod view-only permissions + mod actions
|
||||
if (
|
||||
rank >= 3 &&
|
||||
(slug === PERMS.MOD_DASHBOARD ||
|
||||
(slug.startsWith('mod.') && slug.endsWith('.view')) ||
|
||||
slug === PERMS.MOD_ACTIONS)
|
||||
)
|
||||
return true
|
||||
return false
|
||||
export function canAccess(permissions: PermissionSet, slug: string, _rank?: number): boolean {
|
||||
return permissions.has(slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* For mod panel server components: get session + load permissions.
|
||||
* Redirects to login if not authenticated, to / if rank < 3.
|
||||
* Redirects to login if unauthenticated and to / without moderator ACL access.
|
||||
*/
|
||||
export async function getModContext() {
|
||||
const session = await auth()
|
||||
@@ -157,35 +159,38 @@ export async function getModContext() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (session.user.rank < 3) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) redirect('/login')
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
const state = await getCurrentAuthorizationState(userId)
|
||||
if (!state) redirect('/')
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirect('/')
|
||||
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
|
||||
}
|
||||
|
||||
// ── Legacy single-check functions (kept for backward compatibility) ──
|
||||
|
||||
/** Check if a user has a CMS permission. Rank >= 7 bypasses all checks. */
|
||||
/** Check if a user has a CMS permission using their current database rank. */
|
||||
export async function checkPermission(
|
||||
userId: number,
|
||||
rank: number,
|
||||
_rank: number,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const perms = await loadUserPermissions(userId, rank)
|
||||
const state = await getCurrentAuthorizationState(userId)
|
||||
if (!state) return false
|
||||
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
|
||||
return perms.has(permission)
|
||||
}
|
||||
|
||||
/** Check multiple permissions (user needs ALL of them) */
|
||||
export async function checkAllPermissions(
|
||||
userId: number,
|
||||
rank: number,
|
||||
_rank: number,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const perms = await loadUserPermissions(userId, rank)
|
||||
const state = await getCurrentAuthorizationState(userId)
|
||||
if (!state) return false
|
||||
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
|
||||
return perms.hasAll(...permissions)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { shouldRedirectAdminRequest } from "./proxy-access";
|
||||
|
||||
describe("shouldRedirectAdminRequest", () => {
|
||||
it("redirects anonymous and non-staff admin requests before rendering", () => {
|
||||
it("redirects anonymous admin requests before rendering", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
|
||||
expect(shouldRedirectAdminRequest("/admin/tickets", { rank: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows staff admin requests and never affects public routes", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 7 })).toBe(false);
|
||||
it("defers every authenticated rank to database authorization", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 })).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,5 @@ export interface ProxyToken {
|
||||
|
||||
export function shouldRedirectAdminRequest(pathname: string, token: ProxyToken | null): boolean {
|
||||
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
|
||||
const rank = typeof token?.rank === "number" ? token.rank : Number(token?.rank);
|
||||
return !Number.isInteger(rank) || rank < 7;
|
||||
return token === null;
|
||||
}
|
||||
Reference in new issue
Block a user