Example of an auth directive

Lets say you have a prisma schema like this:

model User {
   id String @id @unique
   username   String
}

model Post {
  author   User   @relation(fields: [authorID], references: [id])
  authorID String
  
  content String
  privateAuthorNotes String
}

You’ve done the usual SDL + services generation etc, and you have users which can write/edit/delete posts. However you have two problems with respect to access control:

  • The privateAuthorNotes is available in the API to everyone
  • You need to make sure that only the author can edit the post

A simple approach is to go and add checks for currentUser?.id === authorID inside the resolver each resolver where you need to make these access checks. I’m not a big everything must be DRY person, but once I had wrote this logic in my codebase about ~10 times, it became worth thinking of a better abstraction.

So, I present my auth directive: ownerOrAdminOnly:

It allows me to describe where you can find the owner’s ID on the object, and gives you the ability to automatically enforce access controls quite simply:

import { AuthenticationError, ForbiddenError, createValidatorDirective, ValidatorDirectiveFunc } from "@redwoodjs/graphql-server"

import { hasRole } from "src/lib/auth"
import { node } from "src/services/objectIdentification/objectIdentification"

export const schema = gql`
  directive @ownerOrAdminOnly(key: String!) on FIELD_DEFINITION
`

const validate: ValidatorDirectiveFunc = async (args) => {
  const context = args.context

  if (!context.currentUser) {
    throw new AuthenticationError("You don't have permission to do that.")
  }

  let existingObj: any = args.root

  if (!existingObj && typeof args.args.id === "string") {
    // We're in a top level query, so we need to get the object via the node interface.
    existingObj = await node({ id: args.args.id })
  }

  const isMe = context.currentUser.id === existingObj[args.directiveArgs.key]
  const isAdmin = hasRole({ roles: "admin" })
  if (!isMe || !isAdmin) throw new ForbiddenError("You don't have permission to do that")
}

const ownerOrAdminOnly = createValidatorDirective(schema, validate)

export default ownerOrAdminOnly

This is the version I use, which relies on the Global Object Identification graphql spec to allow grabbing any arbitrary object via their ID. My codebase’s conformance looks like this.

It means I can write SDL like this:

type User implements Node {
  id: ID!
  username: String!
}

type Post implements Node {
  id: ID!
  author: User!
  content: String!
  privateNotes: String! @ownerOrAdminOnly(key: "authorID")
}


type Mutation {
  updatePost(id: ID!, input: EndUserUserInput!): Post @ownerOrAdminOnly(key: "authorID")
}

For privateNotes - the directive looks at the post object returned by prisma (service not shown in here because it’s the default) and reads the authorID

For updatePost - the directive does not have access to the object yet, and so it makes uses the node function to get the object and then checks the authorID

This removed a chunk of useless resolver implementations which only existed for auth from my codebase. If you’re too far down the line to add the Node interface, then you can use this just for field level auth checks, which is still pretty useful.

Here’s a version that just does that:

import { AuthenticationError, ForbiddenError, createValidatorDirective, ValidatorDirectiveFunc } from "@redwoodjs/graphql-server"

import { hasRole } from "src/lib/auth"

export const schema = gql`
  directive @ownerOrAdminOnly(key: String!) on FIELD_DEFINITION
`

const validate: ValidatorDirectiveFunc = async (args) => {
  const context = args.context

  if (!context.currentUser) {
    throw new AuthenticationError("You don't have permission to do that.")
  }

  const existingObj: any = args.root
  if (!existingObj) {
    throw new AuthenticationError("You can only use @ownerOrAdminOnly on fields for non-root types, e.g. not Query/Mutation.")
  }

  const isMe = context.currentUser.id === existingObj[args.directiveArgs.key]
  const isAdmin = hasRole({ roles: "admin" })
  if (!isMe || !isAdmin) throw new ForbiddenError("You don't have permission to do that")
}

const ownerOrAdminOnly = createValidatorDirective(schema, validate)

export default ownerOrAdminOnly

3 Likes

Love this, when I read the RBAC how to page, I had wished it used directive. Now I have a solution to do that. Thanks

I have gone back and forth about doing RBAC w/in a directive vs w/in a service (or both).

At the time, I thought the logic was best in a service so that logic could be used outside GraphQL – say by another service or in another function.

I think as RedwoodJS starts to do design work for ABAC (like permissions) and some how to’s on multi-tenancy.

So, I anticipate revisiting this soon.

Glad directives are turning out to be more useful that we even first thought!