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)
This commit is contained in:
@@ -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();
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user