Best way to implement input based validation on a router procedure

Hi guys, bit of a noob. I have already created a 'protectedProcedure', ensuring the user is logged in, but for some of my procedures, I also want to ensure the user is an ADMIN for the account specified on the input. This is my first try with validation just added at the top of the procedure implementation...
changeUserAccessWithinAccount: protectedProcedure
.input(z.object({ user_id: z.number(), account_id: z.number(), access: z.enum([ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER, ACCOUNT_ACCESS.READ_ONLY, ACCOUNT_ACCESS.READ_WRITE]) }))
.query(async ({ ctx, input }) => {
// validate that the context user is an admin within the account specified as an input param.... where should this go.. an additional input parser?
const test_membership = ctx.dbUser.memberships.find(membership => membership.account_id == input.account_id);
if(!test_membership || (test_membership?.access !== ACCOUNT_ACCESS.ADMIN && test_membership?.access !== ACCOUNT_ACCESS.OWNER)) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
//....do the thing...
changeUserAccessWithinAccount: protectedProcedure
.input(z.object({ user_id: z.number(), account_id: z.number(), access: z.enum([ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER, ACCOUNT_ACCESS.READ_ONLY, ACCOUNT_ACCESS.READ_WRITE]) }))
.query(async ({ ctx, input }) => {
// validate that the context user is an admin within the account specified as an input param.... where should this go.. an additional input parser?
const test_membership = ctx.dbUser.memberships.find(membership => membership.account_id == input.account_id);
if(!test_membership || (test_membership?.access !== ACCOUNT_ACCESS.ADMIN && test_membership?.access !== ACCOUNT_ACCESS.OWNER)) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
//....do the thing...
Is there a way to implement this with multiple input parsers (https://trpc.io/docs/procedures#multiple-input-parsers) ?? what would that look like?
Define Procedures | tRPC
Procedures in tRPC are very flexible primitives to create backend functions; they use a builder pattern which means you can create reusable base procedures for different parts of your backend application.
8 Replies
Nick
Nick2y ago
Inputs aren’t really meant for this kind of thing, authentication and authorisation are both best handled by middlewares which use the request headers to find a user’s identity and then check their access You can use Meta on the route to specify what role you should be in to access it and the middleware can read this I realise this may be a lot of new words, but it’s all native tRPC and can be found in the docs
JavascriptMick
Thanks Nick, that makes sense but I am trying to implement a multi-tenant model where a single user can be a member of multiple accounts so when a request comes in to mess around with accounts, the request includes the id of the account and the current users access within that account is therefore dependent on the input parameters.... maybe I am completely off the track here, what do you think? Maybe, the 'active' account should be a session property and then the middleware approach might work better @Nick Lucas you may find this disconcerting but I was asking chatgpt the same question and shared our conversation.... and the combination of your context yielded a perfect answer...
Nick
Nick2y ago
Middlewares can access the parsed input if the middleware comes after it in the chain But I think it’s awesome that chatgpt can fill the gaps that I can’t provide from a phone! 😇
JavascriptMick
#win win
Nick
Nick2y ago
It's really remarkable how much of a good starting point that is gpt has also bailed me out a couple times 😄
JavascriptMick
for reference, this is what I ended up with...
/**
* auth middlewares
**/
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user,
},
});
});

const isAdminForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
if (!ctx.dbUser) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const result = z.object({ account_id: z.number() }).safeParse(rawInput);
if (!result.success) throw new TRPCError({ code: 'BAD_REQUEST' });
const { account_id } = result.data;
const test_membership = ctx.dbUser.memberships.find(membership => membership.account_id == account_id);
if(!test_membership || (test_membership?.access !== ACCOUNT_ACCESS.ADMIN && test_membership?.access !== ACCOUNT_ACCESS.OWNER)) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}

return next({ ctx });
});

export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = protectedProcedure.use(isAdminForInputAccountId);
/**
* auth middlewares
**/
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user,
},
});
});

const isAdminForInputAccountId = t.middleware(({ next, rawInput, ctx }) => {
if (!ctx.dbUser) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const result = z.object({ account_id: z.number() }).safeParse(rawInput);
if (!result.success) throw new TRPCError({ code: 'BAD_REQUEST' });
const { account_id } = result.data;
const test_membership = ctx.dbUser.memberships.find(membership => membership.account_id == account_id);
if(!test_membership || (test_membership?.access !== ACCOUNT_ACCESS.ADMIN && test_membership?.access !== ACCOUNT_ACCESS.OWNER)) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}

return next({ ctx });
});

export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = protectedProcedure.use(isAdminForInputAccountId);
Mr.default
Mr.default2y ago
@TrickyMick Would you considered this solved? If so could you check the green checkmark, it could help other developers in the future.