diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 1d78827..911aa4d 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -1,7 +1,7 @@ import { initTRPC, TRPCError } from '@trpc/server'; import superjson from 'superjson'; import { ZodError } from 'zod'; -import type { Context } from './context'; +import type { Context, SessionUser } from './context'; const t = initTRPC.context().create({ transformer: superjson, @@ -36,3 +36,24 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { }, }); }); + +/** + * Role-guarded procedure. Derives from protectedProcedure (auth is enforced first). + * Throws FORBIDDEN (HTTP 403) if the authenticated user's role is not in the + * allowed list. + * + * Usage: + * requireRole('ADMIN', 'SUPERVISOR').query(...) + * requireRole('ADMIN').mutation(...) + */ +export function requireRole(...roles: SessionUser['role'][]) { + return protectedProcedure.use(({ ctx, next }) => { + if (!roles.includes(ctx.user.role)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Role '${ctx.user.role}' is not allowed. Required: ${roles.join(', ')}.`, + }); + } + return next({ ctx }); + }); +} diff --git a/scripts/role-smoke.ts b/scripts/role-smoke.ts new file mode 100644 index 0000000..e9dd788 --- /dev/null +++ b/scripts/role-smoke.ts @@ -0,0 +1,53 @@ +/** + * AC verification for Passo 4: + * requireRole('ADMIN') returns FORBIDDEN for OPERATOR, passes for ADMIN. + * + * Uses createCallerFactory with in-memory contexts — no DB or HTTP required. + */ +import { TRPCError } from '@trpc/server'; +import { router, createCallerFactory, requireRole } from '../packages/api/src/trpc.js'; +import type { Context } from '../packages/api/src/context.js'; + +function makeCtx(role: 'ADMIN' | 'OPERATOR'): Context { + return { + user: { id: 'u1', email: `${role.toLowerCase()}@demo.local`, role, tenantId: 't1' }, + // db / prisma / logger are not touched by the role check, use minimal stubs + db: {} as Context['db'], + prisma: {} as Context['prisma'], + tenantId: 't1', + headers: new Headers(), + logger: console as unknown as Context['logger'], + }; +} + +const testRouter = router({ + adminOnly: requireRole('ADMIN').query(() => ({ ok: true })), +}); + +const createCaller = createCallerFactory(testRouter); + +async function main() { + // OPERATOR must be rejected with FORBIDDEN + try { + await createCaller(makeCtx('OPERATOR')).adminOnly(); + throw new Error('Expected FORBIDDEN but call succeeded'); + } catch (err) { + if (err instanceof TRPCError && err.code === 'FORBIDDEN') { + console.log('OPERATOR → FORBIDDEN ✓'); + } else { + throw err; + } + } + + // ADMIN must succeed + const result = await createCaller(makeCtx('ADMIN')).adminOnly(); + if (!result.ok) throw new Error('ADMIN call returned unexpected result'); + console.log('ADMIN → ok ✓'); + + console.log('\nRole middleware AC PASSED'); +} + +main().catch((err) => { + console.error('Role middleware AC FAILED:', err); + process.exit(1); +});