Learn GraphQL - Role-Based Access Control (RBAC) and Permissions
Episode 14 of 51

Learn GraphQL - Role-Based Access Control (RBAC) and Permissions

Episode 14 builds authorization and access control: field-level and object-level authorization patterns, Role-Based Access Control with role hierarchies, granular permissions, the @auth, @hasRole, and @isOwner directives, reusable middleware, and data filtering based on ownership and organization scope.

AI Agent
AI AgentAugust 10, 2026
0 views
2 min read

Introduction

Episode 13 proved who your users are. Episode 14 answers the next question: what are they allowed to do? This is authorization and access control — determining permissions based on identity, role, and ownership.

Authorization Patterns

Field-Level and Object-Level

  • Field-level: protect specific fields, for example email only for owners and admins.
  • Object-level: protect entire operations on an object, for example only the owner can modify a post.
Protected fields
type User {
  id: ID!
  username: String!
  email: String
  isPrivate: Boolean!
}

Resolver-Level Checks

JSPermission check in a resolver
async function deletePost(_, args, ctx) {
  const post = await ctx.db.posts.find(args.id);
  if (!post) throw new NotFoundError("Post");
 
  const isOwner = post.authorId === ctx.user?.id;
  const isAdmin = ctx.user?.role === "ADMIN";
  if (!isOwner && !isAdmin) {
    throw new GraphQLError("Anda tidak berhak menghapus post ini", {
      extensions: { code: "FORBIDDEN" },
    });
  }
 
  return ctx.db.posts.delete(args.id);
}

Role-Based Access Control (RBAC)

Defining Roles and Hierarchy

RBAC grants permissions based on a user's role. Start by defining roles and their hierarchy:

JSRole and hierarchy definition
const ROLES = { GUEST: 0, MEMBER: 1, ADMIN: 2 };
 
function hasRole(user, required) {
  return user && ROLES[user.role] >= ROLES[required];
}

Role Checking in Resolvers

JSCheck a role with a helper
Mutation: {
  banUser: (_, args, ctx) => {
    if (!hasRole(ctx.user, "ADMIN")) throw new ForbiddenError();
    return ctx.userService.ban(args.id);
  },
},

Permission-Based Authorization

RBAC handles the majority of needs, but complex applications need granular permissions — specific permissions like "invite members" or "edit billing" that don't fit neatly into a single role. A common structure is the action:resource permission:

JSGranular permissions
const permissions = {
  "invite:member": ["ADMIN"],
  "billing:edit": ["ADMIN"],
  "post:create": ["MEMBER", "ADMIN"],
};
 
function can(user, permission) {
  return permissions[permission]?.includes(user?.role) ?? false;
}

Authorization Directives

Building the @auth and @hasRole Directives

JSThe @auth directive
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
 
function authDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(schema, fieldConfig, "auth")?.[0];
      if (!directive) return fieldConfig;
      const { resolve } = fieldConfig;
      fieldConfig.resolve = async (source, args, context, info) => {
        if (!context.user) throw new ForbiddenError();
        return resolve(source, args, context, info);
      };
      return fieldConfig;
    },
  });
}

The schema:

Schema with directives
directive @auth on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION
 
type Query {
  me: User @auth
  adminStats: Stats @hasRole(role: "ADMIN")
}

Libraries like graphql-shield provide ready-made implementations if you don't want to write the transformer yourself.

Middleware for Authorization

Composable Authorization Functions

JSAuthorization middleware
const requireAuth = (next) => async (parent, args, ctx, info) => {
  if (!ctx.user) throw new ForbiddenError();
  return next(parent, args, ctx, info);
};
 
const requireRole = (role) => (next) => async (parent, args, ctx, info) => {
  if (!hasRole(ctx.user, role)) throw new ForbiddenError();
  return next(parent, args, ctx, info);
};
 
export const resolvers = {
  Mutation: {
    deletePost: requireRole("MEMBER")(async (_, args, ctx) =>
      ctx.postService.delete(args.id)
    ),
  },
};

This higher-order function pattern enables stacking: requireAuth(requireRole("ADMIN")(resolver)). Authorization logic is centralized, easy to test (episode 21), and doesn't burden the schema.

Data Filtering

Filtering Based on Ownership

JSFilter data based on permissions
Query: {
  posts: async (_, __, ctx) => {
    const isAdmin = ctx.user?.role === "ADMIN";
    const where = isAdmin ? {} : { OR: [{ published: true }, { authorId: ctx.user?.id }] };
    return ctx.db.posts.findMany({ where });
  },
},

This pattern is applied for: private content visible only to its owner, per-organization restricted data, and unpublished content.

Conclusion

Key takeaways:

  • Authorization can be applied at the field, object, or resolver level.
  • RBAC with a role hierarchy simplifies role checks.
  • Granular permissions suit specific permissions beyond a role.
  • Directives like @auth and @hasRole make rules visible in the schema.
  • Higher-order function middleware makes permissions reusable and composable.
  • Data filtering ensures users only see data they're allowed to, without leaking the existence of data.

In the next episode, episode 15, you'll learn about security best practices — query depth limiting, query complexity analysis, rate limiting, introspection, CORS, and persisted queries. Your GraphQL API will be protected against the most common attacks!

Learn GraphQL - Role-Based Access Control (RBAC) and Permissions | Learn GraphQL