Best practices for user permissions based on schema relationships

I was chatting in the Discord chat about the best way to set up permissions enforcement based on schema relationships…

For a simple example, let’s say I have many Posts that belong to a User… Are there any guidelines on how to lock down the editing of said Posts based on which User owns it?

  • I’d imagine the API side would need some conditional logic to prevent mutations
  • Restrict the “edit” routes of a post to only be accessible if currentUser is the owner of the post
  • As well as provide the web side a mechanism to check if currentUser is the owner of a post for conditional UI like an “edit” button

cc @rob (it was mentioned that you might be able help here as you were solving similar problems)

Thanks!

5 Likes

@pmarsceill Definitely check out this response from Rob over here:

If you’re looking for the Redwood Way to handle roles/permissions you’re kind of stuck—we haven’t invented it yet! But it’s on the roadmap.

If I absolutely had to have roles/permissions right now I’d add some tables to my database:

User
Role
Permissions

And then a couple more tables to handle tracking the relationships between them. You can create these manually or let Prisma do it using their implicit many-to-many syntax:

UserRole (many-to-many, maps a user to a role)
RolePermission (many-to-many, maps a role to a permission)

When you define getCurrentUser() in auth.js you can tell Prisma to include the data from related tables at the same time. This might not work quite as written here, I haven’t worked with deeply nested data in Prisma, but assuming you used the implicit version of the many-to-many relationships, you should be able to do something like this in getCurrentUser() in api/src/lib/auth.js along with a helper to aggregate the list of permissions into an easy list for later:

export const getCurrentUser = ({ email }) => {
  return db.user.findOne({
    where: { email },
    include: { 
      roles: { 
        include: { 
          permissions: true 
        }
      }
    }
  })
}

export const currentPermissions = () => {
  if (context.currentUser) {
    return context.currentUser.roles.map(r => {
      return r.permissions.map(p => {
        return p.name
      })
    })
  } else {
    return []
  }
}

We already give you a helper for requiring that someone be logged in, requireAuth() but you could add another requirePermission() that you could use in a similar fashion:

export const requirePermission = (name) => {
  if (!currentPermissions.includes(name)) {
    throw new AuthenticationError("You don't have permission to do that.")
  }
}

So now in your services you can check for permissions before letting someone do something. You can use the simple requirePermission() check to see if they are allowed to edit contacts at all and then you can enforce that they can only update a contact that they themselves own by first selecting that contact from the database only if it also contains the currentUser’s ID. (Pretend that a Contact model exists that has a userId for the user that owns it):

export const updateContact = async ({ id, input }) => {
  requirePermission('updateContact')

  const foundContact = await db.contact.findMany({ 
    where: { id, userId: context.currentUser.id }
  })

  if (foundContact.length) {
    return db.contact.update({
      data: input,
      where: { id },
    })
  } else {
    throw new AuthenticationError("You do not have access to this contact")
  }
}

And you can do a similar check in your components (in this example I’ll put the check on the Page that contains the edit form) to make sure the currentUser is allowed to even see the page to update a contact (note that we can’t easily use our currentPermissions() helper here because of context so we’ll get that list of permissions manually). If they don’t have permission then we’ll redirect them to the homepage:

import { useAuth } from '@redwoodjs/auth'
import { Redirect, routes } from '@redwoodjs/router'

const EditContactPage => () => {
  const { currentUser } = useAuth()
  const currentPermissions = currentUser.roles.map(r => {
    return r.permissions.map(p => {
      return p.name
    })
  })

  if (!currentPermissions.includes('updateContact')) {
    return <Redirect to={routes.home()} />
  }

  return (
    // edit form
  )
}

Note that as long as you have access to edit a contact this would let you edit ANY contact, so you’d want to a check via a GraphQL query that the user is only selecting a contact that they have access to (similar to the userId select we used above).

The way I do this in one of my own apps is to edit the contact service (the one that returns a single contact found by ID) so it includes the userId check. That way ANY time a contact is selected it’s always scoped by the user that owns it and you can never somehow see a contact that doesn’t belong to you:

export const contact = async ({ id }) => {
  requireAuth()

  const users = await db.contact.findMany({
    where: { id, userId: context.currentUser.id },
  })
  return users[0]
}

I then use this contact service function in my updateContact and deleteContact service functions (in place of foundContact in the examples above) and then that makes sure you can’t update or delete a contact you don’t own, either.

I’ve got an open issue with Prisma that will allow me to just do a regular findOne() query without having to add that users[0]… we’ll see what happens!

Whew! Did that help? :slight_smile:

4 Likes

Whew! Did that help? :slight_smile:

Wow this is epic… Thank you! I will dig into it this week. But yes, it does help! If I have any questions, I’ll follow up here. This is definitely enough to get started though.

1 Like