Validating Permissions

Rrhyssul12/23/2022
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 });
    }),
});


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
A/Kalex / KATT12/23/2022
But haven't OSS'd it yet
A/Kalex / KATT12/23/2022
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
A/Kalex / KATT12/23/2022
Also, in your prisma queries, you can limit your where statement instead of doing an auth check
Rrhyssul12/24/2022
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
A/Kalex / KATT12/24/2022
i guess most procs will be around a "server" then
A/Kalex / KATT12/24/2022
so you can do some base proc like this
A/Kalex / KATT12/24/2022
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 } });
  })
A/Kalex / KATT12/24/2022
or maybe something like
A/Kalex / KATT12/24/2022
// 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(() => { 
     // .....
   })
Rrhyssul12/28/2022
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();
}


  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
Rrhyssul12/28/2022
Rrhyssul1/18/2023
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),
    });
  }),


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
Rrhyssul1/18/2023
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
Nnlucas1/19/2023
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
Nnlucas1/19/2023
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 */)
})
Nnlucas1/19/2023
Ah which is actually what KATT already suggested now I see it. Definitely do what works for you at the end of the day 🙂
Rrhyssul1/20/2023
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 })

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
Rrhyssul1/20/2023
(cc @Nick Lucas forgot to do that as a reply sorry, no rush on responding)
Nnlucas1/20/2023
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.
Nnlucas1/20/2023
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 */)
})
Nnlucas1/20/2023
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