diff --git a/prisma/migrations/0011_dynamic_rank_acl.sql b/prisma/migrations/0011_dynamic_rank_acl.sql new file mode 100644 index 00000000..b625a031 --- /dev/null +++ b/prisma/migrations/0011_dynamic_rank_acl.sql @@ -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 +); diff --git a/src/lib/admin/authorization-contract.test.ts b/src/lib/admin/authorization-contract.test.ts new file mode 100644 index 00000000..dcf266b1 --- /dev/null +++ b/src/lib/admin/authorization-contract.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/admin/authorization-policy.test.ts b/src/lib/admin/authorization-policy.test.ts new file mode 100644 index 00000000..009af4f2 --- /dev/null +++ b/src/lib/admin/authorization-policy.test.ts @@ -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" })); +}); diff --git a/src/lib/admin/authorization-policy.ts b/src/lib/admin/authorization-policy.ts new file mode 100644 index 00000000..03e00508 --- /dev/null +++ b/src/lib/admin/authorization-policy.ts @@ -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" }; +} diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 68a59f19..b7c614d1 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -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 { - 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 { - 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 }; } /** diff --git a/src/lib/admin/rank-authority.test.ts b/src/lib/admin/rank-authority.test.ts new file mode 100644 index 00000000..b9f4febc --- /dev/null +++ b/src/lib/admin/rank-authority.test.ts @@ -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 })); +}); diff --git a/src/lib/admin/rank-authority.ts b/src/lib/admin/rank-authority.ts new file mode 100644 index 00000000..fdd9f33b --- /dev/null +++ b/src/lib/admin/rank-authority.ts @@ -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; +} + +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 }; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index ae24a90c..e4b7e7b0 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -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 { 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 { - 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 { - 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) } diff --git a/src/lib/proxy-access.test.ts b/src/lib/proxy-access.test.ts index 310b3ac9..8de21b6e 100644 --- a/src/lib/proxy-access.test.ts +++ b/src/lib/proxy-access.test.ts @@ -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); }); }); diff --git a/src/lib/proxy-access.ts b/src/lib/proxy-access.ts index 0a8cbecb..63c98431 100644 --- a/src/lib/proxy-access.ts +++ b/src/lib/proxy-access.ts @@ -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; }