/** * requireAdmin — MiddlewareHandler that DB-enforces the admin role (ADMIN-03). * * Security contract (T-10-04, T-10-05): * - Reads users.is_admin from the DB — the bypass only skips OIDC, not this check. * - Never branches on a property of c.get('user') other than .id. * - Non-admins and unauthenticated requests always receive 403 { error: 'Forbidden' }. * - Never logs the user object or any credential (T-10-07). * * Mount FIRST inside any admin sub-router: * adminRouter.use('*', requireAdmin); * * The side-effect import of devBypass.js carries the ContextVariableMap augmentation * so c.get('user') is statically typed (same pattern as other route files). */ // Side-effect import: ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; import type { MiddlewareHandler } from 'hono'; import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users } from '../db/schema.js'; export const requireAdmin: MiddlewareHandler = async (c, next) => { const contextUser = c.get('user') as { id: number } | undefined; const userId = contextUser?.id; // No resolved user — 403 immediately, no DB query if (!userId) { return c.json({ error: 'Forbidden' }, 403); } // Always look up is_admin from the DB. // The dev-auth bypass skips OIDC, not the DB check — this lookup runs on every request. const [row] = await db .select({ isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!row?.isAdmin) { return c.json({ error: 'Forbidden' }, 403); } await next(); };