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

+44 -39
View File
@@ -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)
}