Writing the Rule Into the Schema
Writing the Rule Into the Schema
Scattered requireRole calls work, but the rule is invisible until you open a resolver file. Attached to SDL, the rule travels with the field — readable in the schema, diffable in a pull request, and impossible to overlook when someone adds a sensitive field next week:
enum Role { USER SUPPORT ADMIN }
directive @hasRole(role: Role!) on FIELD_DEFINITION
directive @public on FIELD_DEFINITION
type Query {
posts: [Post!]! @public
me: User @public
auditLog: [AuditEntry!]! @hasRole(role: ADMIN)
systemConfig: SystemConfig! @hasRole(role: SUPPORT)
}
A reviewer reading auditLog now knows its policy without leaving the schema. Change the policy from ADMIN to SUPPORT and the diff shows a permission change, which is exactly what a security reviewer wants to see in review.
The schema also becomes lintable: a rule that rejects any Query field carrying neither @public nor @hasRole makes "deny by default" mechanical instead of aspirational. That lint belongs in CI alongside schema checks — see schema linting rules for where such rules live.
Two design choices to settle up front: what happens to a field with no directive (no wrapper is generated, so it is unprotected — make the lint forbid that state), and whether the directive is documentation only or enforced by the server. Documentation-only directives are how teams end up with a beautiful annotated schema and an open admin endpoint. Enforce it.
One nuance lives in the on clause. FIELD_DEFINITION marks the fields the server resolves, which is where enforcement happens. A directive placed at query position (FIELD) is seen only by client tooling — useful for a generator that hides buttons — but it travels with the document, so a client that removes it loses nothing it needed. Listing both makes the split explicit:
directive @hasRole(role: Role!) on FIELD_DEFINITION | FIELD
The server copy enforces; the query-position copy informs. A policy change then reads in review as @hasRole(role: ADMIN) → @hasRole(role: SUPPORT) on exactly the line a security reviewer needs to see.
How the Directive Wraps the Resolver
How the Directive Wraps the Resolver
SDL directives ship as metadata; something must read that metadata and act. With graphql-tools, mapSchema walks the fields, getDirective reads the arguments, and the transform replaces the resolver with a guarded one:
import { mapSchema, getDirective, MapperKind, defaultFieldResolver } from '@graphql-tools/schema';
export function hasRoleTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const applied = getDirective(schema, fieldConfig, 'hasRole')?.[0];
if (!applied) return fieldConfig; // no directive: no wrapper
const required = applied.role;
const resolve = fieldConfig.resolve ?? defaultFieldResolver;
return {
...fieldConfig,
resolve: (root, args, ctx, info) => {
const roles = ctx.viewer?.roles ?? [];
if (!roles.includes(required)) {
throw new ForbiddenError('Insufficient role', {
extensions: { code: 'FORBIDDEN', requiredRole: required },
});
}
return resolve(root, args, ctx, info); // original resolver runs after
},
};
},
});
}
const schema = hasRoleTransformer(makeExecutableSchema({ typeDefs, resolvers }));
Non-obvious details worth understanding:
- The wrapper runs during field execution, so the check happens per requested field, after context exists and before any data is read. Roles come from verified claims — see request auth context.
?? defaultFieldResolverkeeps the directive working for fields that never declared a resolver; without it, root-field lookups would silently bypass the guard.- Transformer order matters. Stacked transforms compose, so the outermost wrapper runs first. Put authorization last in the chain of wrappers — meaning applied so its check is outermost — and a field-stripping or pagination transform cannot run before the permission decision.
- Reuse with parameters.
@hasRole(role: ADMIN)and@auth(requires: EDITOR)are the same function with different arguments; one directive with an enum argument beats five near-identical directives.
Where Directive Checks Stop Short
Where Directive Checks Stop Short
Directive-based authorization is field-shaped. It answers "may this caller read this field at all?" and stops there — four gaps remain, each owned by something else.
Object ownership. @hasRole(role: USER) lets any user read Query.invoice(id). Whether this invoice belongs to that user is a post-fetch decision that needs the row in hand:
invoice: async (_, { id }, ctx) => {
const row = await invoiceForTenant(id, ctx.viewer.tenantId);
return row; // null when it is not theirs
};
The directive gets you to the resolver; the resolver still scopes. That object-level work is tenant data scoping.
Misconfiguration and coverage. An unprotected field has no wrapper — absence of a directive means absence of a check, and nothing in the type system objects. Directives must be enforced by tests, not trust:
it('denies auditLog to a plain user', async () => {
const res = await execute({ query: '{ auditLog { id } }', contextValue: ctxAs('user') });
expect(res.errors?.[0].extensions.code).toBe('FORBIDDEN');
});
Add a schema-level sweep asserting every sensitive field carries a directive, because a code review can miss one field in a 400-field schema.
Resolution paths that skip transforms. Directives live in your schema construction. A subgraph behind a federation gateway enforces its own copy, a resolver invoked in a unit test never sees the wrapper, and a code path that calls the service layer directly ignores the schema entirely. Enforcement must hold at the layer that touches data.
Client-side value is convenience, not security. Because the rules are in SDL, code generation can emit typed guards so the UI stops offering buttons that would fail — see TypeScript codegen. That improves the experience; the server check remains the only control.
Practice Problems
Declare a @requiresRole directive in SDL for field definitions, then implement the schema transform that wraps every marked field so execution denies callers whose verified roles do not include the required role. Fields without the directive must be rejected by a schema lint rule you also specify.
Example:
Input: SDL: systemConfig: SystemConfig! @requiresRole(role: ADMIN) | context: { viewer: { roles: ["user"] } }
Output: Field execution throws with extensions.code = FORBIDDEN; the resolver body never runs
getDirective reads the ADMIN argument off the field config, the wrapper compares it against ctx.viewer.roles before delegating, and the guard throws — so no data access happens for a caller without the role.
Optimal Solution — O(F) to transform and lint, O(1) per field execution time, O(F) space
Define the directive in SDL, wrap marked fields with mapSchema during schema construction, and enforce directive coverage with a validation rule over the built schema
// SDL
directive @requiresRole(role: Role!) on FIELD_DEFINITION
enum Role { USER SUPPORT ADMIN }
// transform
import { mapSchema, getDirective, MapperKind, defaultFieldResolver } from '@graphql-tools/schema';
export const requiresRoleTransformer = (schema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const d = getDirective(schema, fieldConfig, 'requiresRole')?.[0];
if (!d) return fieldConfig;
const resolve = fieldConfig.resolve ?? defaultFieldResolver;
const required = d.role;
return {
...fieldConfig,
resolve: (root, args, ctx, info) => {
const roles = ctx.viewer?.roles ?? []; // fail closed on missing context
if (!roles.includes(required)) {
throw new ForbiddenError('Insufficient role', {
extensions: { code: 'FORBIDDEN', requiredRole: required },
});
}
return resolve(root, args, ctx, info);
},
};
},
});
// coverage lint: fail the build, not the request
export function assertDirectiveCoverage(schema) {
const query = schema.getQueryType();
for (const [name, field] of Object.entries(query.getFields())) {
const marks = getDirective(schema, field, 'requiresRole')
.concat(getDirective(schema, field, 'public'));
if (marks.length === 0) throw new Error('Query.' + name + ' has no access directive');
}
} Edge Cases:
- Field with no explicit resolver: fall back to defaultFieldResolver so the guard still wraps it
- Missing ctx.viewer (anonymous): roles default to [] and the check denies
- Two directives on one field: apply authorization outermost so no other transform runs before the check
Quiz
1. When does a @hasRole directive check execute relative to the field resolver?
2. A field in Query has no @hasRole and no @public directive. What is its effective access policy?
3. What does auth metadata in SDL enable for the client, and what does it not do?
Flashcards
Question
Schema directive for auth
Click to reveal answer
Answer
An SDL directive such as @hasRole(role: ADMIN) on FIELD_DEFINITION that documents and triggers a guard for that field.
Question
Directive transform
Click to reveal answer
Answer
mapSchema + getDirective read SDL metadata and replace the resolver with a wrapper that checks context roles before delegating.
Question
Directive coverage gap
Click to reveal answer
Answer
A field with no directive gets no wrapper — enforce coverage with a schema lint rule plus per-field denial tests.
Question
What directives cannot do
Click to reveal answer
Answer
Check object ownership after fetch, protect service-layer calls that bypass the schema, or secure a client that queries anyway.
Question
defaultFieldResolver fallback
Click to reveal answer
Answer
Wrap it too, or fields without an explicit resolver bypass the guard while still resolving from parent values.
Revision Notes
Key Takeaways
- 1. Rules in SDL are reviewable, lintable, and diffable — a permission change shows up in the pull request
- 2. The transform wraps the resolver so the check runs per field execution before any data is read
- 3. Directives cover field access only: ownership, coverage, and non-schema call paths need their own controls
Interview Tips
- • Say explicitly where the directive check runs and what it reads from — "it is in the schema" is not an answer to how it enforces
- • Be ready with the test story: how you prove every sensitive field is covered and actually denies
Cheat Sheet
Authorization Directives Cheat Sheet
SDL
directive @hasRole(role: Role!) on FIELD_DEFINITION
type Query {
me: User @public
auditLog: [AuditEntry!]! @hasRole(role: ADMIN)
}
Guard wrapper (graphql-tools)
mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fc) => {
const d = getDirective(schema, fc, 'hasRole')?.[0];
if (!d) return fc; // no directive = unprotected
const resolve = fc.resolve ?? defaultFieldResolver;
return { ...fc, resolve: (r, a, ctx, i) => {
if (!(ctx.viewer?.roles ?? []).includes(d.role))
throw new ForbiddenError('...', { extensions: { code: 'FORBIDDEN' } });
return resolve(r, a, ctx, i);
}};
}})
Rules: lint for directive coverage · apply auth transform outermost · test denial per sensitive field · object ownership stays in the resolver
Client side: codegen emits typed guards — convenience only, never enforcement