Rhys
Rhysβ€’2y ago

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:
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 });
}),
});
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 function 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
10 Replies
Alex / KATT 🐱
Alex / KATT 🐱 (@alexdotjs)
This week I've been working on a declarative isomorphic permission library. On the backend we create Prisma WhereInputs & on the frontend we get rules like this hopefully will OSS it soon
Likes
798
Twitter
Alex / KATT 🐱
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 Also, in your prisma queries, you can limit your where statement instead of doing an auth check
Rhys
Rhysβ€’2y ago
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
Alex / KATT 🐱
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 } });
})
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(() => {
// .....
})
// 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(() => {
// .....
})
Rhys
Rhysβ€’2y ago
I ended up implementing this in a functional way
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();
}
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 }),
});
})
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
Rhys
Rhysβ€’2y ago
GitHub
AnswerOverflow/channel_settings.ts at API Β· AnswerOverflow/AnswerOv...
Index Discord Questions into Google. Contribute to AnswerOverflow/AnswerOverflow development by creating an account on GitHub.
Rhys
Rhysβ€’17mo ago
Completely necroposting on this to share how I'm handling permissions now to help others out in the future 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),
});
}),
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
Nick
Nickβ€’17mo ago
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 */)
})
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 πŸ™‚
Rhys
Rhysβ€’17mo ago
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
.meta({ server_id: input.server_id })
.meta({ server_id: input.server_id })
or
.meta({ server_id: ctx.prisma.channels(input.channel_id).server_id) canEdit: true})
.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)
Nick
Nickβ€’17mo ago
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 */)
})
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