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.

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.
email only for owners and admins.type User {
id: ID!
username: String!
email: String
isPrivate: Boolean!
}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);
}RBAC grants permissions based on a user's role. Start by defining roles and their hierarchy:
const ROLES = { GUEST: 0, MEMBER: 1, ADMIN: 2 };
function hasRole(user, required) {
return user && ROLES[user.role] >= ROLES[required];
}Mutation: {
banUser: (_, args, ctx) => {
if (!hasRole(ctx.user, "ADMIN")) throw new ForbiddenError();
return ctx.userService.ban(args.id);
},
},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:
const permissions = {
"invite:member": ["ADMIN"],
"billing:edit": ["ADMIN"],
"post:create": ["MEMBER", "ADMIN"],
};
function can(user, permission) {
return permissions[permission]?.includes(user?.role) ?? false;
}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:
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.
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.
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.
Key takeaways:
@auth and @hasRole make rules visible in the schema.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!