import { unstable_cache } from 'next/cache' import { redirect } from 'next/navigation' import { cache } from 'react' 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' import { PERMS } from './permission-slugs' // ── Permission Set ────────────────────────────────────────────────── export type { PermissionSet } from '@/types/admin' import type { PermissionSet } from '@/types/admin' function createEmptySet(): PermissionSet { return { has: () => false, hasAny: () => false, hasAll: () => false, isSuperAdmin: false, } } /** * Fetch all permission slugs for a user in a single SQL query. * Cached via unstable_cache with 60s TTL — invalidated via revalidateTag('permissions'). */ const getCachedPermissionSlugs = unstable_cache( async (userId: number, rank: number): Promise => { const rows = await prisma.$queryRaw<{ slug: string }[]>` SELECT DISTINCT p.slug FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id IN ( SELECT amr.role_id FROM acl_model_roles amr WHERE amr.model_type = 'User' AND amr.model_id = ${userId} UNION SELECT ar.id FROM acl_roles ar WHERE ar.slug = ${`rank_${rank}`} ) ` return rows.map((r) => r.slug) }, ['user-permissions'], { revalidate: 60, tags: ['permissions'] }, ) /** * 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 (isDynamicSuperAdmin(rank, highestRank)) { return { has: () => true, hasAny: () => true, hasAll: () => true, isSuperAdmin: true, } } const slugArray = await getCachedPermissionSlugs(userId, rank) if (slugArray.length === 0) return createEmptySet() const slugs = new Set(slugArray) return { has: (perm: string) => slugs.has(perm), hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)), hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)), isSuperAdmin: false, } } catch (error) { await logAuthorizationEvent({ kind: 'permission.load_error', userId, rank, source: 'loadUserPermissions', reason: 'ACL query failed', error }) // Fail-closed: return empty set on any error return createEmptySet() } }) 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 ───────────────────────────────────────────────── /** * For server components: get session + load permissions. * Redirects to login if not authenticated. */ export async function getAdminContext() { const session = await auth() if (!session?.user) { redirect('/login') } const userId = sessionUserId(session.user.id) if (!userId) redirect('/login') 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 } } /** * For API routes: get session + load permissions. * Returns null if not authenticated (caller handles 401). */ export async function getApiAdminContext() { const session = await auth() if (!session?.user) return null const userId = sessionUserId(session.user.id) if (!userId) return null 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. * All fallbacks are represented as ACL role permissions by migration 0011. */ 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 unauthenticated and to / without moderator ACL access. */ export async function getModContext() { const session = await auth() if (!session?.user) { redirect('/login') } const userId = sessionUserId(session.user.id) if (!userId) redirect('/login') 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 using their current database rank. */ export async function checkPermission( userId: number, _rank: number, permission: string, ): Promise { 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, permissions: string[], ): Promise { const state = await getCurrentAuthorizationState(userId) if (!state) return false const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank) return perms.hasAll(...permissions) } /** Check if user has admin access */ export async function hasAdminAccess(userId: number, rank: number): Promise { return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD) }