Skip to content

Commit 24830fa

Browse files
committed
feat(auth): support shield options, don't default to allow, add unauthorised error and assertion utils, add role and scope based rule builders, add shield doc
1 parent 4e4af6e commit 24830fa

5 files changed

Lines changed: 277 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,21 @@ const graphqlServer = createServer({
9191

9292
`context.requestInfo` is built for every request — both HTTP and websocket subscription connects — so downstream code can distinguish sources, rebuild URLs, pass through correlation headers, etc.
9393

94-
| Field | Description |
95-
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
96-
| `requestId` | `x-request-id` header if present, otherwise a freshly generated UUID. |
97-
| `source` | `'http'` for regular requests, `'subscription'` for websocket connects. |
98-
| `protocol` | `'http'` / `'https'` for HTTP, `'ws'` / `'wss'` for subscriptions (resolved via `x-forwarded-proto` or TLS socket encryption). |
99-
| `host` | Hostname only (no port). Prefers `x-forwarded-host`, falls back to the `Host` header, then `req.hostname` (Express only). |
100-
| `port` | Port parsed from `x-forwarded-host` / `Host` header when present; `undefined` otherwise. |
94+
| Field | Description |
95+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
96+
| `requestId` | `x-request-id` header if present, otherwise a freshly generated UUID. |
97+
| `source` | `'http'` for regular requests, `'subscription'` for websocket connects. |
98+
| `protocol` | `'http'` / `'https'` for HTTP, `'ws'` / `'wss'` for subscriptions (resolved via `x-forwarded-proto` or TLS socket encryption). |
99+
| `host` | Hostname only (no port). Prefers `x-forwarded-host`, falls back to the `Host` header, then `req.hostname` (Express only). |
100+
| `port` | Port parsed from `x-forwarded-host` / `Host` header when present; `undefined` otherwise. |
101101
| `baseUrl` | Fully-qualified origin (`scheme://host[:port]`) with default ports stripped. For subscriptions the scheme is normalised to `http(s)` so the value composes with relative URLs. |
102-
| `url` | `req.originalUrl` for HTTP, `req.url` for subscription connects. |
103-
| `origin` | `Origin` header. |
104-
| `referer` | `Referer` header. |
105-
| `correlationId` | `x-correlation-id` header. |
106-
| `arrLogId` | `x-arr-log-id` header (Azure Front Door / ARR). |
107-
| `clientIp` | First value from `x-forwarded-for`, falling back to `socket.remoteAddress`. |
108-
| `userAgent` | `User-Agent` header. |
102+
| `url` | `req.originalUrl` for HTTP, `req.url` for subscription connects. |
103+
| `origin` | `Origin` header. |
104+
| `referer` | `Referer` header. |
105+
| `correlationId` | `x-correlation-id` header. |
106+
| `arrLogId` | `x-arr-log-id` header (Azure Front Door / ARR). |
107+
| `clientIp` | First value from `x-forwarded-for`, falling back to `socket.remoteAddress`. |
108+
| `userAgent` | `User-Agent` header. |
109109

110110
You can add more via `augmentRequestInfo(input)`. Lambda deployments also get `functionName` and `awsRequestId` when a `LambdaContext` is supplied.
111111

@@ -163,6 +163,112 @@ const graphqlServer = createServer({
163163
})
164164
```
165165

166+
## Shield (authorization)
167+
168+
The `shield` module provides typed helpers around [graphql-shield](https://the-guild.dev/graphql/shield) for defining authorization middleware against your generated `Resolvers` type.
169+
170+
`graphql-shield` is an optional peer dependency — install it only if you use this module:
171+
172+
```sh
173+
npm i graphql-shield
174+
```
175+
176+
### createShieldSchema
177+
178+
Builds a shield middleware with a schema typed against your resolver map, so field-level rule wiring is checked by the TypeScript compiler.
179+
180+
```ts
181+
import { Resolvers } from '../generated/types'
182+
import { or } from 'graphql-shield'
183+
import { applyMiddleware } from 'graphql-middleware'
184+
import { createShieldSchema, hasRoleRule, unauthorisedError } from '@makerx/graphql-core'
185+
186+
const isCoordinator = hasRoleRule('coordinator')
187+
const isSystemAdmin = hasRoleRule('system-admin')
188+
const isAuthorisedUser = or(isCoordinator, isSystemAdmin)
189+
190+
const shieldSchema = createShieldSchema<Resolvers>(
191+
{
192+
Query: {
193+
'*': isAuthorisedUser,
194+
user: isCoordinator,
195+
},
196+
Mutation: {
197+
'*': isCoordinator,
198+
createUser: isSystemAdmin,
199+
},
200+
},
201+
{ fallbackRule: isAuthorisedUser, fallbackError: unauthorisedError() },
202+
)
203+
204+
const schema = applyMiddleware(makeExecutableSchema({ ... }), shieldSchema)
205+
```
206+
207+
The `'*'` key at each object level is a fallback rule applied to any field at that level without an explicit rule.
208+
209+
The wrapper defaults `allowExternalErrors: true`; the full shield options object (second argument) is forwarded to `shield(...)` and can override it.
210+
211+
#### v3 breaking change: no default fallback rule
212+
213+
Prior to v3 this wrapper defaulted `fallbackRule` to `allow` — fields not covered by the schema were open by default. v3 removes that default and the wrapper no longer makes that choice for you. Without an explicit `fallbackRule`, graphql-shield's own default (`allow`) still applies, so behaviour is unchanged when no options are passed, but if you want a deny-by-default posture you should now pass an explicit `fallbackRule` (commonly your auth-check rule) along with a `fallbackError`.
214+
215+
The second argument also changed from a bare `ShieldRule` to the full shield options object:
216+
217+
```ts
218+
// v2
219+
createShieldSchema<Resolvers>(schema, isAuthorisedUser)
220+
221+
// v3
222+
createShieldSchema<Resolvers>(schema, { fallbackRule: isAuthorisedUser, fallbackError: unauthorisedError() })
223+
```
224+
225+
### Role and scope rules
226+
227+
Convenience builders that read `ctx.user.roles` / `ctx.user.scopes`. All use graphql-shield's `'contextual'` cache so the result is reused across fields within a request.
228+
229+
| Helper | Description |
230+
| ---------------------------------------- | ----------------------------------------------------------------------------- |
231+
| `hasRoleRule(role, ruleName?)` | Passes when `user.roles` includes `role`. |
232+
| `hasAnyRoleRule(...roles)` | Passes when `user.roles` includes at least one of `roles`. |
233+
| `hasAnyRoleRuleWithName(name, ...roles)` | Same, with an explicit rule name / cache key for large or dynamic role lists. |
234+
| `hasScopeRule(scope)` | Passes when `user.scopes` includes `scope`. |
235+
| `hasAnyScopeRule(...scopes)` | Passes when `user.scopes` includes at least one of `scopes`. |
236+
237+
### createRule
238+
239+
Typed wrapper around graphql-shield's `rule(...)` for ad-hoc rules. Unlike the underlying `rule`, `createRule` types `parent`, `args` and `ctx` to your generics.
240+
241+
```ts
242+
const isOwner = createRule<GraphQLContext, Record, { ownerId: string }>((parent, _, ctx) => parent.ownerId === ctx.user?.id)
243+
```
244+
245+
Defaults to `'strict'` cache; pass `'contextual'` as the second argument for rules whose result depends only on `ctx`.
246+
247+
### combineRuleWithAll
248+
249+
Composes a rule with every rule in an existing shield schema via a combinator (`and` | `or` | `chain` | `race`). Useful for post-hoc gating — e.g. bolting a "not blocked" check onto an existing schema without touching each field:
250+
251+
```ts
252+
import { chain } from 'graphql-shield'
253+
254+
const gated = combineRuleWithAll(shieldSchema, accountNotBlocked, chain)
255+
```
256+
257+
### Authorization errors
258+
259+
The `unauthorised` module provides shared helpers for producing forbidden errors used by the shield wrapper and by resolvers.
260+
261+
| Helper | Description |
262+
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
263+
| `unauthorisedError(message?)` | Builds a `GraphQLError` with Apollo's `FORBIDDEN` extension code and HTTP 403 status. |
264+
| `throwUnauthorised(message?)` | Throws `unauthorisedError`; return type is `never` so TypeScript narrows control flow past the call. |
265+
| `permissionInvariant(condition, message?)` | Assertion helper: throws `unauthorisedError` when `condition` is false, otherwise narrows it to `true`. |
266+
267+
```ts
268+
permissionInvariant(ctx.user?.id === record.ownerId, 'Only the owner can edit this record')
269+
// past this line, TypeScript knows the condition held
270+
```
271+
166272
## Logging
167273

168274
### logGraphQLOperation
@@ -223,7 +329,6 @@ This library includes a `subscriptions` module to provide simple setup using the
223329
1. Create a subscriptions server, using the ws-server cleanup function in your server lifecycle.
224330
225331
The `useSubscriptionsServer` function sets up:
226-
227332
- Auth token validation as part of establishing (or rejecting) the connection (behaviour defined by `verifyToken` and `requireAuth` args)
228333
- GraphQL context creation
229334
- Logging from the server `onConnect`, `onDisconnect`, `onOperation`, `onNext` and `onError` callbacks

‎eslint.config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default [
3131
{
3232
rules: {
3333
'@typescript-eslint/no-explicit-any': 'off',
34+
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports', fixStyle: 'separate-type-imports' }],
3435
},
3536
},
3637
{

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export * from './logging'
33
export * from './request-info'
44
export * from './schema-util'
55
export * from './type-utils'
6+
export * from './unauthorised'
67
export * from './User'
78
export * from './utils'

‎src/shield.ts‎

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1-
import type { and, chain, IRules, or, race } from 'graphql-shield'
2-
import { allow, rule, shield } from 'graphql-shield'
1+
import type { allow, and, chain, IRules, or, race } from 'graphql-shield'
2+
import { rule, shield } from 'graphql-shield'
3+
import type { GraphQLContext } from './context'
34
import type { Primitive } from './type-utils'
45

6+
export type ShieldOptions = NonNullable<Parameters<typeof shield>[1]>
7+
58
type RuleCombinator = typeof chain | typeof race | typeof or | typeof and
69
// For whatever reason, graphql-shield doesn't export this type, but we can extract if from
710
// what it does export.
811
export type ShieldRule = ReturnType<(typeof allow)['getRules']>[number]
12+
13+
/**
14+
* Thin typed wrapper around graphql-shield's `rule(...)` for building ad-hoc shield rules.
15+
*
16+
* Lets you pass a plain predicate (sync or async, or returning an `Error`) and get back a
17+
* {@link ShieldRule} with the `parent`, `args`, and `ctx` arguments typed to your generics —
18+
* graphql-shield's native `rule` types these as `any`.
19+
*
20+
* Defaults the rule's cache to `'strict'`; use `'contextual'` when the result depends only on
21+
* `ctx` (e.g. the current user), so it can be reused across fields in the same request.
22+
*
23+
* @param logic Predicate returning `true` to allow, `false` to deny, or an `Error` to deny with a specific error.
24+
* @param cache graphql-shield cache strategy. `'strict'` (default) keys on parent+args+ctx; `'contextual'` keys on ctx only.
25+
*
26+
* Usage:
27+
*
28+
* const isOwner = createRule<GraphQLContext, { ownerId: string }>(
29+
* (parent, _, ctx) => parent.ownerId === ctx.user?.id,
30+
* )
31+
*/
932
export const createRule = <TContext, TParent = unknown, TArgs = unknown>(
1033
logic: (parent: TParent, args: TArgs, ctx: TContext) => boolean | Promise<boolean> | Error,
1134
cache: 'contextual' | 'strict' = 'strict',
@@ -35,33 +58,113 @@ export function combineRuleWithAll<T>(schema: ShieldSchema<T>, rule: ShieldRule,
3558
/**
3659
* Creates graphql shield middleware making use of generics to type the definition of the rules.
3760
* @param schema A mapping of schema objects to the shield rules that define the access to that object
38-
* @param fallbackRule A fallback rule to apply to any object or property which doesn't have a defined rule.
61+
* @param options Shield options forwarded to `shield`. Defaults `allowExternalErrors` to `true`.
3962
*
4063
* Usage:
4164
* import { Resolvers } from '../generated/types'
4265
*
66+
* const isCoordinator = hasRoleRule(UserRoles.Coordinator)
67+
* const isSystemAdmin = hasRoleRule(UserRoles.SystemAdmin)
68+
* const isQualityAssurance = hasRoleRule(UserRoles.QualityAssurance)
69+
*
70+
* const isAuthorisedUser = or(isCoordinator, isSystemAdmin, isQualityAssurance)
71+
* const isDataAuthor = or(isCoordinator, isSystemAdmin)
72+
*
4373
* const shieldSchema = createShieldSchema<Resolvers>({
4474
* Query: {
45-
* getThings: allow,
46-
* dontGetThings: deny,
47-
* }
48-
* })
49-
*
50-
* let schema = makeExecutableSchema({...})
75+
* '*': isAuthorisedUser,
76+
* user: isCoordinator,
77+
* findUsers: isCoordinator,
78+
* },
79+
* Mutation: {
80+
* '*': isDataAuthor,
81+
* createUser: isCoordinator,
82+
* },
83+
* // specific rules for mutations, types, fields...
84+
* }, { fallbackRule: isAuthorisedUser, fallbackError: unauthorisedError()})
5185
*
52-
* schema = applyMiddleware(schema, shieldSchema)
86+
* const unprotected = makeExecutableSchema({...})
87+
* return applyMiddleware(unprotected, shieldSchema)
5388
*/
54-
export function createShieldSchema<TRootResolvers>(schema: ShieldSchema<TRootResolvers>, fallbackRule: ShieldRule = allow) {
89+
export function createShieldSchema<TRootResolvers>(schema: ShieldSchema<TRootResolvers>, options?: ShieldOptions) {
5590
return shield(schema as IRules, {
5691
allowExternalErrors: true,
57-
fallbackRule: fallbackRule,
92+
...options,
5893
})
5994
}
6095

96+
/**
97+
* A shield rule tree typed against a resolver map (typically your generated `Resolvers` type).
98+
*
99+
* Mirrors the shape of `TResolver` so each type/field can be assigned a {@link ShieldRule}.
100+
* Object levels may also include a `'*'` key — a fallback rule applied to any field at that
101+
* level that doesn't have an explicit rule. Leaf (primitive) positions collapse to a single
102+
* {@link ShieldRule}.
103+
*
104+
* Usage:
105+
*
106+
* const schema: ShieldSchema<Resolvers> = {
107+
* Query: {
108+
* '*': isAuthorisedUser,
109+
* publicThing: allow,
110+
* },
111+
* Mutation: { createUser: isCoordinator },
112+
* }
113+
*/
61114
export type ShieldSchema<TResolver> = TResolver extends Primitive
62115
? ShieldRule
63116
: {
64117
[key in keyof TResolver]?: ShieldSchema<TResolver[key]> | ShieldRule
65118
} & {
66119
'*'?: ShieldRule
67120
}
121+
122+
/**
123+
* Shield rule that passes when the current user has the given role.
124+
* @param role The role the user must have on `ctx.user.roles`.
125+
* @param ruleName Optional override for the rule's cache key. Defaults to `hasRole-${role}`.
126+
*/
127+
export function hasRoleRule(role: string, ruleName?: string) {
128+
return rule(ruleName ?? `hasRole-${role}`, { cache: 'contextual' })(
129+
(_, __, { user }: GraphQLContext) => user?.roles.includes(role) === true,
130+
)
131+
}
132+
133+
/**
134+
* Shield rule that passes when the current user has at least one of the given roles.
135+
* Rule name is derived from the roles; use {@link hasAnyRoleRuleWithName} to supply your own.
136+
* @param roles The set of roles, any of which satisfies the rule.
137+
*/
138+
export function hasAnyRoleRule(...roles: string[]) {
139+
return hasAnyRoleRuleWithName(`hasAnyRole-${roles.join(',')}`, ...roles)
140+
}
141+
142+
/**
143+
* Shield rule that passes when the current user has at least one of the given roles, with an explicit rule name.
144+
* Prefer this over {@link hasAnyRoleRule} when the role list is large or dynamic and you want a stable cache key.
145+
* @param ruleName The name (and cache key) for the rule.
146+
* @param roles The set of roles, any of which satisfies the rule.
147+
*/
148+
export function hasAnyRoleRuleWithName(ruleName: string, ...roles: string[]) {
149+
return rule(ruleName, { cache: 'contextual' })((_, __, { user }: GraphQLContext) => {
150+
return user?.roles.some((role: string) => roles.includes(role)) === true
151+
})
152+
}
153+
154+
/**
155+
* Shield rule that passes when the current user has the given scope.
156+
* @param scope The scope the user must have on `ctx.user.scopes`.
157+
*/
158+
export function hasScopeRule(scope: string) {
159+
return rule(`hasScope-${scope}`, { cache: 'contextual' })((_, __, { user }: GraphQLContext) => user?.scopes.includes(scope) === true)
160+
}
161+
162+
/**
163+
* Shield rule that passes when the current user has at least one of the given scopes.
164+
* @param scopes The set of scopes, any of which satisfies the rule.
165+
*/
166+
export function hasAnyScopeRule(...scopes: string[]) {
167+
return rule(`hasAnyScope-${scopes.join(',')}`, { cache: 'contextual' })(
168+
(_, __, { user }: GraphQLContext) => user?.scopes.some((scope: string) => scopes.includes(scope)) === true,
169+
)
170+
}

0 commit comments

Comments
 (0)