MAI CALL - step 4
This commit is contained in:
parent
8e57ccc7f0
commit
f10a346356
@ -1,7 +1,7 @@
|
|||||||
import { initTRPC, TRPCError } from '@trpc/server';
|
import { initTRPC, TRPCError } from '@trpc/server';
|
||||||
import superjson from 'superjson';
|
import superjson from 'superjson';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import type { Context } from './context';
|
import type { Context, SessionUser } from './context';
|
||||||
|
|
||||||
const t = initTRPC.context<Context>().create({
|
const t = initTRPC.context<Context>().create({
|
||||||
transformer: superjson,
|
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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
53
scripts/role-smoke.ts
Normal file
53
scripts/role-smoke.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user