From f9c70ab6a8f68f2f8fed6a85a5880bd562cd5a30 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 14:32:04 -0400 Subject: [PATCH] feat(10-02): implement requireAdmin DB-backed MiddlewareHandler - reads users.isAdmin from DB (never trusts context user's isAdmin claim) - 403 with { error: 'Forbidden' } for non-admins and missing user - side-effect import of devBypass.js for ContextVariableMap augmentation - bypass path skips OIDC only, not the DB check (T-10-04/T-10-05) --- apps/api/src/lib/requireAdmin.ts | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/api/src/lib/requireAdmin.ts diff --git a/apps/api/src/lib/requireAdmin.ts b/apps/api/src/lib/requireAdmin.ts new file mode 100644 index 0000000..2746442 --- /dev/null +++ b/apps/api/src/lib/requireAdmin.ts @@ -0,0 +1,47 @@ +/** + * 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(); +};