201 lines
7.0 KiB
TypeScript
201 lines
7.0 KiB
TypeScript
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<string[]> => {
|
|
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<PermissionSet> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD)
|
|
}
|