T
Join ServertRPC
❓-help
Validating Permissions
Hi!
A common operation that I'm doing in tRPC is validating that a person has permissions to perform the action they're trying to do, i.e:
Where
Having to put that inside of each router is a bit ugly, but the information to ensure the caller can edit isn't available inside of Context so that's the only place that I can think to put it.
Is this the best approach to this where I just make an assert function at throw that inside of the procedure or is there some better way of doing this? Thanks
A common operation that I'm doing in tRPC is validating that a person has permissions to perform the action they're trying to do, i.e:
const serverCreateUpdateRouter = router({
create: protectedProcedureWithUserServers
.input(server_create_input)
.mutation(({ ctx, input }) => {
assertCanEditServer(ctx, input.id);
return ctx.prisma.server.create({ data: input });
}),
update: protectedProcedureWithUserServers
.input(server_update_input)
.mutation(({ ctx, input }) => {
assertCanEditServer(ctx, input.id);
return ctx.prisma.server.update({ where: { id: input.id }, data: input });
}),
});
Where
assertCanEditServer
is the permissions check. In this instance, I'm taking the ID of the server they're editing and comparing it against a list of server permissions to validate they can edit that server. I'd like to find a better way of doing this instead of just having to repeat a bunch of code with this assertCanEditServer functionHaving to put that inside of each router is a bit ugly, but the information to ensure the caller can edit isn't available inside of Context so that's the only place that I can think to put it.
Is this the best approach to this where I just make an assert function at throw that inside of the procedure or is there some better way of doing this? Thanks
I've actually done a bit of work on this https://twitter.com/alexdotjs/status/1588205093576675328?t=dd8KODcSmAxA2tElBpfAwg&s=19
But haven't OSS'd it yet
But some stuff to give you places to look or get ideas
- use multiple input parsers (e.g. a base procedure that does auth checks and see that the user is in a specific orgsnizarik if it's a multitenant SaaS thing)
- use metadata to decorate permissions
- have auth resolvers based on the entity
- use multiple input parsers (e.g. a base procedure that does auth checks and see that the user is in a specific orgsnizarik if it's a multitenant SaaS thing)
- use metadata to decorate permissions
- have auth resolvers based on the entity
Also, in your prisma queries, you can limit your where statement instead of doing an auth check
Thanks for the reply! I’ll experiment with those methods and see what works best
Limiting it in the Prisma query is an interesting approach although what I’m using to check is fetching from the Discord API and storing that in memory so I can’t use that approach
Limiting it in the Prisma query is an interesting approach although what I’m using to check is fetching from the Discord API and storing that in memory so I can’t use that approach
i guess most procs will be around a "server" then
so you can do some base proc like this
const serverProc = t.procedure
.input(z.object({ serverId: z.string() }))
.use(async opts => {
const permissions = null; /* .... get permissions for opts.ctx.id in ops.input.serverId */
return opts.next({ ctx: { permsissions } });
})
or maybe something like
// base proc
const channelProc = t.procedure
.input(z.object({ channelId: z.string() }))
.use(async opts => {
if (!opts.meta.permissions?.length) throw new Error('server error - gotta setup permissions for ' + opts.path)
if (opts.meta.permissions.some(perm => !opts.ctx.permissions.includes(perm)) throw new TRPCError({ code: "BAD_REQUEST" })
return opts.next({ ctx: { permsissions } });
})
// actual proc in the router
const sendMessage = channelProc
.input( z.object({ text: z.string() } )
.meta({ permissions: ['send-message'] })
.mutation(() => {
// .....
})
I ended up implementing this in a functional way
Still figuring out if this is a way I like or not but so far it seems good - I can take it a step further and make the auth check a functional element as well to have different permission checks
export async function protectedFetch<T>(input: ProtectedFetchInput<T>) {
const { fetch, ctx, not_found_message } = input;
const data = await findOrThrowNotFound(fetch, not_found_message);
const server_id = input.getServerId(data);
assertCanEditServers(ctx, server_id);
return data;
}
// 2. Scenario 2, we already have the data and can assertCanEditServer
export async function protectedOperation<T>(input: {
operation: () => Promise<T>;
// eslint-disable-next-line no-unused-vars
server_id: string | string[];
ctx: Context;
}) {
const { operation, server_id, ctx } = input;
assertCanEditServers(ctx, server_id);
return operation();
}
create: protectedProcedureWithUserServers.input(z_server_create).mutation(({ ctx, input }) => {
return protectedOperation({
ctx,
server_id: input.id,
operation: () => ctx.prisma.server.create({ data: input }),
});
})
Still figuring out if this is a way I like or not but so far it seems good - I can take it a step further and make the auth check a functional element as well to have different permission checks
Sample file from the branch I'm working on where this is heavily used
https://github.com/AnswerOverflow/AnswerOverflow/blob/API/packages/api/src/router/channel/channel_settings.ts
https://github.com/AnswerOverflow/AnswerOverflow/blob/API/packages/api/src/router/channel/channel_settings.ts
Completely necroposting on this to share how I'm handling permissions now to help others out in the future
Here's two variants
It makes adding / removing permissions from procedures super easy - along with that I'm now just using middlewares for data prep and putting the relevant permissions inside of the procedure itself so that way it's readable as to what permissions are required for the route
Here's two variants
create: publicProcedure.input(z_channel_create).mutation(({ ctx, input }) => {
return protectedMutation({
operation: () => ctx.prisma.channel.create({ data: input }),
permissions: [() => canEditServer(ctx, input.server_id), () => isCtxCallerDiscordBot(ctx)],
});
}),
byId: publicProcedure.input(z.string()).query(async ({ ctx, input }) => {
return protectedFetchWithPublicData({
fetch: () => ctx.prisma.channel.findUnique({ where: { id: input } }),
permissions: (data) => canEditServer(ctx, data.server_id),
not_found_message: "Channel does not exist",
public_data_formatter: (data) => z_channel_public.parse(data),
});
}),
It makes adding / removing permissions from procedures super easy - along with that I'm now just using middlewares for data prep and putting the relevant permissions inside of the procedure itself so that way it's readable as to what permissions are required for the route
Once I get this merged into my main I'll put some links here of code snippets, really happy with this approach for it - still could use some expanding on but for now sticking with this
That work can definitely be pushed up to a middleware and Meta on the procedure used to define what protection level/roles each procedure has
It can be as simple as
protectedProcedure = publicProcesure.use(AuthMiddleware)
router({
create: protectedProcedure
.input().output()
.meta({ canEdit: true })
.mutation(/* now only the operation needs to be here and nothing needed importing but the middleware */)
})
Ah which is actually what KATT already suggested now I see it. Definitely do what works for you at the end of the day 🙂
I might be missing something with this but I think with this approach I'm not able to pass in values from the input into it
To give a few examples
For validating permissions when editing a server, I have to check if the user can edit the server id that was passed in from the input
For validating permissions when editing channel settings, i first have to fetch the channel from the database and then check against the server id on that channel if they can edit
From my understanding I can't access input values from anywhere except for the mutation/query which results in putting the validation inside of this
If I could do something like this with dynamically setting meta properties
or
then I'd be able to move this into the meta which would be nice
To give a few examples
For validating permissions when editing a server, I have to check if the user can edit the server id that was passed in from the input
For validating permissions when editing channel settings, i first have to fetch the channel from the database and then check against the server id on that channel if they can edit
From my understanding I can't access input values from anywhere except for the mutation/query which results in putting the validation inside of this
If I could do something like this with dynamically setting meta properties
.meta({ server_id: input.server_id })
or
.meta({ server_id: ctx.prisma.channels(input.channel_id).server_id) canEdit: true})
then I'd be able to move this into the meta which would be nice
(cc @Nick Lucas forgot to do that as a reply sorry, no rush on responding)
Makes sense, middlewares do have access to the parsed/validated input I believe, but then your challenge becomes having a middleware which can understand the input type, by convention or configuration.
You might end up with something like this to invert control up to the middleware
protectedProcedure = publicProcesure.use(AuthMiddleware)
interface Meta {
accessValidator: (stuff) => boolean
}
router({
create: protectedProcedure
.input().output()
.meta({ accessValidator: serverIdAccessValidator })
.mutation(/* now only the operation needs to be here and nothing needed importing but the middleware */)
})
Since I haven't built this for my own needs obviously I revert to your expertise, and inverting control to middlewares is definitely my opinion