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