How it works
The explanation, with examples you can run.
Many paths, one field
type Query { me: User post(id: ID!): Post search(text: String!): [SearchResult!]!}type Post { author: User! }type User { id: ID! name: String! email: String }Three different routes lead to User.email. A check on Query.me protects only the first:
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
Where the rule lives
// users.js: the business layer, shared by every entry pointexport async function getEmail(viewer, user) { if (!viewer) return null if (viewer.id === user.id || viewer.roles.includes("support")) return user.email return null} // resolvers.jsconst resolvers = { User: { email: (user, _args, ctx) => users.getEmail(ctx.viewer, user) },}Pass the business layer a fully loaded user, not a raw token, so it can decide on roles and ownership. Some servers offer schema directives such as @auth(requires: ADMIN); they are a convenient way to attach rules to fields, but they still need to call the same business logic, and they can't express ownership rules ("authors see their own drafts") without it.
Patterns that help
- Filter lists at the source. A resolver that loads 100 posts and then hides 60 has paged wrongly; ask the data layer for "posts this viewer may see".
- Mind batching. A DataLoader keyed only by id returns whatever it loaded first; since loaders are per request and per viewer, that is fine, but never share one between users.
- Mutations need checks too, on the object being changed, not only on the operation:
updatePost(id)must confirm this viewer owns that post. - Don't confirm existence. For objects the viewer can't see, returning null is often safer than an error that says "forbidden".
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.