@simtlix/simfinity-core 3.5.0 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -4,6 +4,8 @@
4
4
  * distinct from false, so negation cannot turn an invalid comparison into a grant.
5
5
  */
6
6
 
7
+ import { normalizeObjectId } from './object-id.js';
8
+
7
9
  const invalidResult = Symbol('invalid policy result');
8
10
 
9
11
  const isValidRef = (refPath) => {
@@ -25,6 +27,27 @@ const isValidValue = (value) => {
25
27
  const isDenseArray = value => Array.isArray(value)
26
28
  && Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
27
29
 
30
+ const isPlainValue = value => value !== null && typeof value === 'object'
31
+ && (Array.isArray(value) || [Object.prototype, null].includes(Object.getPrototypeOf(value)));
32
+
33
+ // Literals are compared as they are, so a `{ ref }` nested inside one would never be resolved.
34
+ const containsReference = (value, seen = new Set()) => {
35
+ if (!isPlainValue(value) || seen.has(value)) return false;
36
+ if (isReference(value)) return true;
37
+ seen.add(value);
38
+ return Object.values(value).some(item => containsReference(item, seen));
39
+ };
40
+
41
+ const isMembershipItem = value => value === null || ['string', 'boolean', 'bigint'].includes(typeof value)
42
+ || (typeof value === 'number' && Number.isFinite(value)) || normalizeObjectId(value) !== undefined;
43
+
44
+ const isValidComparison = (operator, operand) => {
45
+ if (!isDenseArray(operand) || operand.length !== 2 || !operand.every(isValidValue)) return false;
46
+ if (operand.some(value => !isReference(value) && containsReference(value))) return false;
47
+ if (operator !== 'in' || isReference(operand[1])) return true;
48
+ return isDenseArray(operand[1]) && operand[1].every(isMembershipItem);
49
+ };
50
+
28
51
  const validateExpression = (expression, ancestors = new Set()) => {
29
52
  if (typeof expression === 'boolean') return true;
30
53
  if (expression === null || typeof expression !== 'object' || Array.isArray(expression)) return false;
@@ -38,8 +61,7 @@ const validateExpression = (expression, ancestors = new Set()) => {
38
61
  switch (operator) {
39
62
  case 'eq':
40
63
  case 'in':
41
- return isDenseArray(operand) && operand.length === 2 && operand.every(isValidValue)
42
- && (operator !== 'in' || Array.isArray(operand[1]) || isReference(operand[1]));
64
+ return isValidComparison(operator, operand);
43
65
  case 'allOf':
44
66
  case 'anyOf':
45
67
  return isDenseArray(operand) && operand.every(expr => validateExpression(expr, ancestors));
@@ -65,19 +87,56 @@ const resolveRef = (refPath, context) => {
65
87
  return value;
66
88
  };
67
89
 
90
+ /**
91
+ * An operand of a created rule. The literal or reference classification is made once, when the
92
+ * rule is created, so mutating a literal object afterwards cannot turn it into a reference.
93
+ */
94
+ class CompiledOperand {
95
+ constructor(reference, value) {
96
+ this.reference = reference;
97
+ this.value = value;
98
+ Object.freeze(this);
99
+ }
100
+ }
101
+
68
102
  /** Resolve a literal or reference without coercing its value. */
69
103
  const resolveValue = (value, context) => {
104
+ if (value instanceof CompiledOperand) return value.reference ? resolveRef(value.value, context) : value.value;
70
105
  return isReference(value) ? resolveRef(value.ref, context) : value;
71
106
  };
72
107
 
108
+ // Registered ObjectIds compare by hexadecimal value; every other value compares strictly.
109
+ const comparable = value => normalizeObjectId(value) ?? value;
110
+
111
+ // Strict equality never matches values of different types, such as a string and a number or a
112
+ // primitive and a list. Such a comparison is invalid, so `not` cannot turn it into a grant.
113
+ // null stays comparable with anything, as an explicit check for a missing value.
114
+ const isMismatch = (left, right) => left !== null && right !== null && typeof left !== typeof right;
115
+
116
+ const evaluateMembership = (target, list) => {
117
+ // A list or plain object is not a member value.
118
+ if (!Array.isArray(list) || isPlainValue(target)) return invalidResult;
119
+ // Never call the list's own includes: Mongoose arrays cast and compare loosely.
120
+ let mismatched = false;
121
+ for (let index = 0; index < list.length; index++) {
122
+ const item = comparable(list[index]);
123
+ if (item === target) return true;
124
+ if (isMismatch(target, item)) mismatched = true;
125
+ }
126
+ // Without a match, an item of another type means the list could not be checked reliably.
127
+ return mismatched ? invalidResult : false;
128
+ };
129
+
73
130
  const evaluateComparison = (operator, operands, context) => {
74
131
  const left = resolveValue(operands[0], context);
75
132
  const right = resolveValue(operands[1], context);
76
133
  if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function') {
77
134
  return invalidResult;
78
135
  }
79
- if (operator === 'eq') return left === right;
80
- return Array.isArray(right) ? right.includes(left) : invalidResult;
136
+ const target = comparable(left);
137
+ if (operator === 'in') return evaluateMembership(target, right);
138
+ const other = comparable(right);
139
+ return isMismatch(target, other) ? invalidResult : target === other;
81
140
  };
82
141
 
83
142
  const evaluateAllOf = (expressions, context) => {
@@ -140,12 +199,46 @@ export const evaluateExpression = (expression, context) => {
140
199
  /** Check the complete AST, including boolean literals and every nested branch. */
141
200
  export const isPolicyExpression = value => validateExpression(value);
142
201
 
202
+ // Copy by index with built-in methods only, so an Array subclass cannot alter the copy.
203
+ const copyArray = (array, transform = value => value) => Array.from(
204
+ { length: array.length },
205
+ (_, index) => transform(array[index], index),
206
+ );
207
+
208
+ const compileOperand = (operator, value, index) => {
209
+ if (isReference(value)) return new CompiledOperand(true, value.ref);
210
+ // A literal membership list is copied, so only its validated items are compared.
211
+ if (operator === 'in' && index === 1) return new CompiledOperand(false, Object.freeze(copyArray(value)));
212
+ // Other literals keep their identity, which equality compares.
213
+ return new CompiledOperand(false, value);
214
+ };
215
+
216
+ const snapshotOperands = (operator, operand) => Object.freeze(
217
+ copyArray(operand, (value, index) => compileOperand(operator, value, index)),
218
+ );
219
+
220
+ /**
221
+ * Copy the validated structure, so evaluation needs no revalidation and later changes to the
222
+ * configured object cannot make it malformed. Other literal operands keep their identity.
223
+ */
224
+ const snapshotExpression = (expression) => {
225
+ if (typeof expression === 'boolean') return expression;
226
+ const snapshot = {};
227
+ for (const [operator, operand] of Object.entries(expression)) {
228
+ if (operator === 'not') snapshot[operator] = snapshotExpression(operand);
229
+ else if (operator === 'eq' || operator === 'in') snapshot[operator] = snapshotOperands(operator, operand);
230
+ else snapshot[operator] = Object.freeze(copyArray(operand, snapshotExpression));
231
+ }
232
+ return Object.freeze(snapshot);
233
+ };
234
+
143
235
  /** Create a rule, rejecting malformed policies at configuration time. */
144
236
  export const createRuleFromExpression = (expression) => {
145
237
  if (!isPolicyExpression(expression)) {
146
238
  throw new TypeError('Invalid authorization policy expression');
147
239
  }
148
- return (parent, args, ctx) => evaluateExpression(expression, { parent, args, ctx });
240
+ const snapshot = snapshotExpression(expression);
241
+ return (parent, args, ctx) => evaluateValidatedExpression(snapshot, { parent, args, ctx }) === true;
149
242
  };
150
243
 
151
244
  const expressions = {
package/src/auth/index.js CHANGED
@@ -9,13 +9,12 @@
9
9
  * - Default allow/deny policies
10
10
  *
11
11
  * @example
12
- * import { auth } from '@simtlix/simfinity-js';
12
+ * import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
13
13
  * import { createYoga } from 'graphql-yoga';
14
14
  *
15
- * const { createAuthPlugin, requireAuth, requireRole } = auth;
16
- *
15
+ * // Simfinity names its roots `RootQueryType` and `Mutation`.
17
16
  * const permissions = {
18
- * Query: {
17
+ * RootQueryType: {
19
18
  * series: requireAuth(),
20
19
  * seasons: requireAuth(),
21
20
  * },
@@ -32,7 +31,7 @@
32
31
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
33
32
  */
34
33
 
35
- import { GraphQLError, GraphQLObjectType, defaultFieldResolver } from 'graphql';
34
+ import { GraphQLError, GraphQLObjectType, defaultFieldResolver, __Field } from 'graphql';
36
35
  import SimfinityError from '../errors/simfinity.error.js';
37
36
  import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
38
37
  import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
@@ -128,6 +127,27 @@ const normalizeRule = (rule) => {
128
127
  const isPermissionMap = value => value !== null && typeof value === 'object'
129
128
  && [Object.prototype, null].includes(Object.getPrototypeOf(value));
130
129
 
130
+ /**
131
+ * Simfinity adds an `extensions` field to introspection's `__Field`. Its object types are shared by
132
+ * every schema in the process and describe the schema itself, so authorization skips them like
133
+ * `__` types unless the permission map names them. They are matched by identity.
134
+ */
135
+ const isSimfinityMetadataType = (type) => {
136
+ const extensionsType = __Field.getFields().extensions?.type;
137
+ if (!(extensionsType instanceof GraphQLObjectType)) return false;
138
+ return type === extensionsType || type === extensionsType.getFields().relation?.type;
139
+ };
140
+
141
+ const isExemptMetadataType = (permissions, type) => isSimfinityMetadataType(type)
142
+ && !Object.hasOwn(permissions, type.name);
143
+
144
+ // Schemas processed by any auth plugin instance. A wrapper defers to the other instances only in
145
+ // these schemas; in a schema no instance processed, every wrapper keeps enforcing its rules.
146
+ const authorizedSchemas = new WeakSet();
147
+
148
+ const isThenable = value => value !== null && (typeof value === 'object' || typeof value === 'function')
149
+ && typeof value.then === 'function';
150
+
131
151
  const validateConfiguration = (permissions, defaultPolicy) => {
132
152
  if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
133
153
  throw new TypeError('defaultPolicy must be ALLOW or DENY');
@@ -262,6 +282,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
262
282
  * Returns a middleware object keyed by type name, each containing field resolvers
263
283
  */
264
284
  return async (resolve, parent, args, ctx, info) => {
285
+ if (isExemptMetadataType(permissions, info.parentType)) return resolve(parent, args, ctx, info);
265
286
  const typeName = info.parentType.name;
266
287
  const fieldName = info.fieldName;
267
288
 
@@ -305,35 +326,20 @@ export const createAuthMiddleware = (permissions, options = {}) => {
305
326
  };
306
327
 
307
328
  /**
308
- * Creates a field-level middleware object from a permission schema.
309
- * This can be used with graphql-middleware's applyMiddleware.
329
+ * Creates graphql-middleware authorization for every object field of a schema.
330
+ *
331
+ * It returns the same function as {@link createAuthMiddleware}. graphql-middleware applies a
332
+ * function middleware to every field, so `'*'` rules and the default policy also apply to fields
333
+ * the permission map does not name. A map of only the named fields would leave the others open.
310
334
  *
311
335
  * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
312
336
  * can cause duplicate-type errors when the schema contains custom introspection extensions.
313
337
  *
314
338
  * @param {PermissionSchema} permissions - The permission schema
315
339
  * @param {AuthMiddlewareOptions} [options={}] - Middleware options
316
- * @returns {Object} Field middleware object compatible with graphql-middleware
340
+ * @returns {Function} A graphql-middleware compatible middleware function
317
341
  */
318
- export const createFieldMiddleware = (permissions, options = {}) => {
319
- const middleware = createAuthMiddleware(permissions, options);
320
- const fieldMiddleware = {};
321
-
322
- for (const typeName of Object.keys(permissions)) {
323
- fieldMiddleware[typeName] = {};
324
-
325
- const typePerms = permissions[typeName];
326
- for (const fieldName of Object.keys(typePerms)) {
327
- if (fieldName === '*') {
328
- // Wildcard rules are handled by the middleware internally
329
- continue;
330
- }
331
- fieldMiddleware[typeName][fieldName] = middleware;
332
- }
333
- }
334
-
335
- return fieldMiddleware;
336
- };
342
+ export const createFieldMiddleware = (permissions, options = {}) => createAuthMiddleware(permissions, options);
337
343
 
338
344
  /**
339
345
  * Creates an Envelop-compatible authorization plugin that wraps schema resolvers in-place.
@@ -347,21 +353,25 @@ export const createFieldMiddleware = (permissions, options = {}) => {
347
353
  * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
348
354
  *
349
355
  * @example
350
- * import { auth } from '@simtlix/simfinity-js';
356
+ * import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
351
357
  * import { createYoga } from 'graphql-yoga';
352
358
  *
359
+ * // Keys are the schema's type names: Simfinity names its query root `RootQueryType`.
353
360
  * const permissions = {
354
- * Query: {
355
- * users: requireAuth(),
356
- * adminDashboard: requireRole('ADMIN')
361
+ * RootQueryType: {
362
+ * series: requireAuth(),
363
+ * serie: requireAuth(),
357
364
  * },
358
- * User: {
365
+ * Mutation: {
366
+ * deleteserie: requireRole('ADMIN'),
367
+ * },
368
+ * serie: {
359
369
  * '*': requireAuth(),
360
- * email: requireRole('ADMIN')
361
- * }
370
+ * budget: requireRole('ADMIN'),
371
+ * },
362
372
  * };
363
373
  *
364
- * const authPlugin = auth.createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
374
+ * const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
365
375
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
366
376
  */
367
377
  export const createAuthPlugin = (permissions, options = {}) => {
@@ -374,57 +384,108 @@ export const createAuthPlugin = (permissions, options = {}) => {
374
384
 
375
385
  const log = debug ? console.log.bind(console, '[auth]') : () => {};
376
386
  const processedSchemas = new WeakSet();
387
+ // Schemas built from the same types share their field objects, so each field is wrapped once.
388
+ const wrappers = new WeakMap();
389
+ let warnedAboutQueryRoot = false;
390
+
391
+ const warnAboutQueryRoot = (schema) => {
392
+ if (warnedAboutQueryRoot || !Object.hasOwn(permissions, 'Query') || schema.getType('Query')) return;
393
+ const queryRoot = schema.getQueryType()?.name;
394
+ if (!queryRoot) return;
395
+ warnedAboutQueryRoot = true;
396
+ console.warn(`[auth] Permissions for type "Query" match no type in the schema. Its query root is named "${queryRoot}"; did you mean "${queryRoot}"?`);
397
+ };
398
+
399
+ const createFieldResolver = (schema, typeName, fieldName, rules, query, originalResolve) => {
400
+ const key = `${typeName}.${fieldName}`;
401
+ const denied = () => Promise.reject(new ForbiddenError(`Access denied to ${key}`));
402
+
403
+ // Errors keep reaching GraphQL as rejections, as they did when the whole check was async.
404
+ const resolveField = (parent, args, ctx, info) => {
405
+ try {
406
+ return originalResolve(parent, args, ctx, info);
407
+ } catch (error) {
408
+ return Promise.reject(error);
409
+ }
410
+ };
411
+
412
+ const grant = (parent, args, ctx, info) => {
413
+ if (!query) return resolveField(parent, args, ctx, info);
414
+ return authorizeQueryPaths(permissions, defaultPolicy, info?.schema ?? schema, query, args, ctx, info)
415
+ .then(() => resolveField(parent, args, ctx, info));
416
+ };
417
+
418
+ // Rules run in order and synchronously until one returns a promise.
419
+ const runRules = (index, parent, args, ctx, info) => {
420
+ for (let current = index; current < rules.length; current++) {
421
+ if (debug) log(`Executing rule for ${key}`);
422
+ let result;
423
+ try {
424
+ result = rules[current](parent, args, ctx, info);
425
+ } catch (error) {
426
+ return Promise.reject(error);
427
+ }
428
+ if (isThenable(result)) {
429
+ return Promise.resolve(result).then(value => (
430
+ value === true || value === undefined ? runRules(current + 1, parent, args, ctx, info) : deny()
431
+ ));
432
+ }
433
+ if (result !== true && result !== undefined) return deny();
434
+ }
435
+ if (debug) log(`Access granted to ${key}`);
436
+ return grant(parent, args, ctx, info);
437
+ };
438
+
439
+ const deny = () => {
440
+ if (debug) log(`Rule denied access to ${key}`);
441
+ return denied();
442
+ };
443
+
444
+ return (parent, args, ctx, info) => {
445
+ // In a schema that only other auth plugin instances processed, their rules apply, not these.
446
+ if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
447
+ return originalResolve(parent, args, ctx, info);
448
+ }
449
+ if (debug) log(`Checking ${key}`);
450
+
451
+ if (rules === null) {
452
+ if (debug) log(`No rules for ${key}, applying default policy: ${defaultPolicy}`);
453
+ return defaultPolicy === 'DENY' ? denied() : grant(parent, args, ctx, info);
454
+ }
455
+ return runRules(0, parent, args, ctx, info);
456
+ };
457
+ };
377
458
 
378
459
  const wrapSchemaResolvers = (schema) => {
379
460
  if (processedSchemas.has(schema)) return;
380
461
  validateConfiguration(permissions, defaultPolicy);
462
+ warnAboutQueryRoot(schema);
381
463
 
382
464
  const typeMap = schema.getTypeMap();
383
465
 
384
466
  for (const [typeName, type] of Object.entries(typeMap)) {
385
- if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__')) continue;
467
+ if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__') || isExemptMetadataType(permissions, type)) continue;
386
468
 
387
469
  const fields = type.getFields();
388
470
 
389
471
  for (const [fieldName, field] of Object.entries(fields)) {
472
+ // Skip fields this plugin already wraps; a resolver replaced since then is wrapped again.
473
+ const wrapper = wrappers.get(field);
474
+ if (wrapper !== undefined && wrapper === field.resolve) continue;
475
+
390
476
  const rules = getFieldRules(permissions, typeName, fieldName);
391
- const originalResolve = field.resolve || defaultFieldResolver;
392
477
  const query = field.extensions?.simfinityQuery;
478
+ // An allowed field with no rule and no client query paths has nothing to check.
479
+ if (rules === null && defaultPolicy === 'ALLOW' && !query && !debug) continue;
393
480
 
394
- field.resolve = async (parent, args, ctx, info) => {
395
- log(`Checking ${typeName}.${fieldName}`);
396
-
397
- if (rules === null) {
398
- log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
399
-
400
- if (defaultPolicy === 'DENY') {
401
- throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
402
- }
403
-
404
- await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
405
- return originalResolve(parent, args, ctx, info);
406
- }
407
-
408
- for (const rule of rules) {
409
- log(`Executing rule for ${typeName}.${fieldName}`);
410
-
411
- const allowed = await executeRule(rule, parent, args, ctx, info);
412
-
413
- if (!allowed) {
414
- log(`Rule denied access to ${typeName}.${fieldName}`);
415
- throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
416
- }
417
- }
418
-
419
- await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
420
- log(`Access granted to ${typeName}.${fieldName}`);
421
-
422
- return originalResolve(parent, args, ctx, info);
423
- };
481
+ const resolver = createFieldResolver(schema, typeName, fieldName, rules, query, field.resolve || defaultFieldResolver);
482
+ field.resolve = resolver;
483
+ wrappers.set(field, resolver);
424
484
  }
425
485
  }
426
486
 
427
487
  processedSchemas.add(schema);
488
+ authorizedSchemas.add(schema);
428
489
  };
429
490
 
430
491
  return {
package/src/auth/rules.js CHANGED
@@ -26,6 +26,24 @@ export const resolvePath = (obj, pathOrFn) => {
26
26
  return undefined;
27
27
  };
28
28
 
29
+ // Built-in rules also have a check that returns true, false or a factory for the denial error.
30
+ // anyRule uses it so a denial that a later rule overrides never builds and throws an error.
31
+ // Checks are keyed by the rule function itself: a property would be copied by Object.assign or
32
+ // read through a Proxy, and anyRule would then skip the checks of a wrapping function.
33
+ const checks = new WeakMap();
34
+
35
+ const ruleFromCheck = (check) => {
36
+ const rule = (parent, args, ctx, info) => {
37
+ const result = check(parent, args, ctx, info);
38
+ if (typeof result === 'function') throw result();
39
+ return result;
40
+ };
41
+ checks.set(rule, check);
42
+ return rule;
43
+ };
44
+
45
+ const unauthenticated = () => new UnauthenticatedError('You must be logged in to access this resource');
46
+
29
47
  /**
30
48
  * Rule that requires the user to be authenticated
31
49
  * Checks for user existence at the specified path in context
@@ -37,16 +55,10 @@ export const resolvePath = (obj, pathOrFn) => {
37
55
  * requireAuth('session.currentUser') // checks ctx.session.currentUser
38
56
  */
39
57
  export const requireAuth = (userPath = 'user') => {
40
- return (_parent, _args, ctx) => {
41
- if (!ctx) {
42
- throw new UnauthenticatedError('You must be logged in to access this resource');
43
- }
44
- const user = resolvePath(ctx, userPath);
45
- if (!user) {
46
- throw new UnauthenticatedError('You must be logged in to access this resource');
47
- }
58
+ return ruleFromCheck((_parent, _args, ctx) => {
59
+ if (!ctx || !resolvePath(ctx, userPath)) return unauthenticated;
48
60
  return true;
49
- };
61
+ });
50
62
  };
51
63
 
52
64
  /**
@@ -62,29 +74,40 @@ export const requireAuth = (userPath = 'user') => {
62
74
  * requireRole('ADMIN', { userPath: 'auth.user', rolePath: 'roles.primary' })
63
75
  */
64
76
  export const requireRole = (role, options = {}) => {
65
- const roles = Array.isArray(role) ? role : [role];
77
+ // Copy the list, so changing the caller's array later cannot bypass this validation.
78
+ const roles = Object.freeze(Array.isArray(role) ? Array.from(role) : [role]);
66
79
  if (roles.length === 0 || Array.from(roles).some(value => typeof value !== 'string' || value.length === 0)) {
67
80
  throw new TypeError('Required roles must be a nonempty string or array of nonempty strings');
68
81
  }
69
82
  const { userPath = 'user', rolePath = 'role' } = options;
83
+ const forbidden = () => new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
70
84
 
71
- return (_parent, _args, ctx) => {
72
- if (!ctx) {
73
- throw new UnauthenticatedError('You must be logged in to access this resource');
74
- }
85
+ return ruleFromCheck((_parent, _args, ctx) => {
86
+ if (!ctx) return unauthenticated;
75
87
  const user = resolvePath(ctx, userPath);
76
- if (!user) {
77
- throw new UnauthenticatedError('You must be logged in to access this resource');
78
- }
88
+ if (!user) return unauthenticated;
79
89
 
80
90
  const userRole = resolvePath(user, rolePath);
91
+ return roles.includes(userRole) ? true : forbidden;
92
+ });
93
+ };
81
94
 
82
- if (!roles.includes(userRole)) {
83
- throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
84
- }
85
-
86
- return true;
87
- };
95
+ /**
96
+ * Validate the claims and find the required permissions they hold in one pass, without copying
97
+ * them. Returns null for malformed claims (including holes) and true for a standalone '*' entry.
98
+ * Nothing is cached: a context can outlive a request, and claims can change in place.
99
+ */
100
+ const findHeldPermissions = (claims, requiredPermissions) => {
101
+ if (!Array.isArray(claims)) return null;
102
+ let wildcard = false;
103
+ const held = new Set();
104
+ for (let index = 0; index < claims.length; index++) {
105
+ const claim = claims[index];
106
+ if (typeof claim !== 'string' || claim.length === 0) return null;
107
+ if (claim === '*') wildcard = true;
108
+ else if (requiredPermissions.includes(claim)) held.add(claim);
109
+ }
110
+ return wildcard || held;
88
111
  };
89
112
 
90
113
  /**
@@ -100,42 +123,33 @@ export const requireRole = (role, options = {}) => {
100
123
  * requirePermission('posts:read', { userPath: 'auth.user', permissionsPath: 'grants' })
101
124
  */
102
125
  export const requirePermission = (permission, options = {}) => {
103
- const requiredPermissions = Array.isArray(permission) ? permission : [permission];
126
+ // Copy the list, so changing the caller's array later cannot bypass this validation.
127
+ const requiredPermissions = Object.freeze(Array.isArray(permission) ? Array.from(permission) : [permission]);
104
128
  if (requiredPermissions.length === 0
105
129
  || Array.from(requiredPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
106
130
  throw new TypeError('Required permissions must be a nonempty string or array of nonempty strings');
107
131
  }
108
132
  const { userPath = 'user', permissionsPath = 'permissions' } = options;
109
133
 
110
- return (_parent, _args, ctx) => {
111
- if (!ctx) {
112
- throw new UnauthenticatedError('You must be logged in to access this resource');
113
- }
134
+ return ruleFromCheck((_parent, _args, ctx) => {
135
+ if (!ctx) return unauthenticated;
114
136
  const user = resolvePath(ctx, userPath);
115
- if (!user) {
116
- throw new UnauthenticatedError('You must be logged in to access this resource');
117
- }
137
+ if (!user) return unauthenticated;
118
138
 
119
- const userPermissions = resolvePath(user, permissionsPath);
120
- if (!Array.isArray(userPermissions)
121
- || Array.from(userPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
122
- throw new ForbiddenError('User permissions must be an array of nonempty strings');
139
+ const held = findHeldPermissions(resolvePath(user, permissionsPath), requiredPermissions);
140
+ if (held === null) {
141
+ return () => new ForbiddenError('User permissions must be an array of nonempty strings');
123
142
  }
124
143
 
125
- // Check if user has wildcard permission
126
- if (userPermissions.includes('*')) {
144
+ // A wildcard entry grants every permission
145
+ if (held === true) {
127
146
  return true;
128
147
  }
129
148
 
130
149
  // All required permissions must be present
131
- for (const perm of requiredPermissions) {
132
- if (!userPermissions.includes(perm)) {
133
- throw new ForbiddenError(`Missing permission: ${perm}`);
134
- }
135
- }
136
-
137
- return true;
138
- };
150
+ const missing = requiredPermissions.find(perm => !held.has(perm));
151
+ return missing === undefined ? true : () => new ForbiddenError(`Missing permission: ${missing}`);
152
+ });
139
153
  };
140
154
 
141
155
  /**
@@ -144,6 +158,10 @@ export const requirePermission = (permission, options = {}) => {
144
158
  * @returns {Function} Composed rule function
145
159
  */
146
160
  export const composeRules = (...rules) => {
161
+ // With no rules the composition would grant everything; use allow() for an intentional grant.
162
+ if (rules.length === 0) {
163
+ throw new TypeError('composeRules requires at least one rule');
164
+ }
147
165
  if (rules.some(rule => typeof rule !== 'function')) {
148
166
  throw new TypeError('composeRules requires rule functions');
149
167
  }
@@ -166,25 +184,38 @@ export const composeRules = (...rules) => {
166
184
  * @returns {Function} Combined rule function
167
185
  */
168
186
  export const anyRule = (...rules) => {
187
+ if (rules.length === 0) {
188
+ throw new TypeError('anyRule requires at least one rule');
189
+ }
169
190
  if (rules.some(rule => typeof rule !== 'function')) {
170
191
  throw new TypeError('anyRule requires rule functions');
171
192
  }
172
193
  return async (parent, args, ctx, info) => {
173
194
  let lastError = null;
195
+ // A built-in rule's denial is kept as a factory and only built if it is the error thrown.
196
+ let lastErrorIsFactory = false;
174
197
 
175
198
  for (const rule of rules) {
176
199
  try {
177
- const result = await rule(parent, args, ctx, info);
178
- if (result === true || result === undefined) {
200
+ const check = checks.get(rule);
201
+ const result = check ? check(parent, args, ctx, info) : await rule(parent, args, ctx, info);
202
+ if (check && typeof result === 'function') {
203
+ lastError = result;
204
+ lastErrorIsFactory = true;
205
+ } else if (result === true || result === undefined) {
179
206
  return true; // At least one rule passed
180
207
  }
181
208
  } catch (error) {
182
209
  lastError = error;
210
+ lastErrorIsFactory = false;
183
211
  // Continue to next rule
184
212
  }
185
213
  }
186
214
 
187
215
  // No rule passed - throw the last error or return false
216
+ if (lastErrorIsFactory) {
217
+ throw lastError();
218
+ }
188
219
  if (lastError) {
189
220
  throw lastError;
190
221
  }
@@ -214,14 +245,10 @@ const normalizeOwnerId = (value) => {
214
245
  export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
215
246
  const { userPath = 'user' } = options;
216
247
 
217
- return (parent, _args, ctx) => {
218
- if (!ctx) {
219
- throw new UnauthenticatedError('You must be logged in to access this resource');
220
- }
248
+ return ruleFromCheck((parent, _args, ctx) => {
249
+ if (!ctx) return unauthenticated;
221
250
  const user = resolvePath(ctx, userPath);
222
- if (!user) {
223
- throw new UnauthenticatedError('You must be logged in to access this resource');
224
- }
251
+ if (!user) return unauthenticated;
225
252
 
226
253
  // Get ownerId from parent (using path or function)
227
254
  const ownerId = normalizeOwnerId(resolvePath(parent, ownerField));
@@ -234,7 +261,7 @@ export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {})
234
261
  }
235
262
 
236
263
  return ownerId === userId;
237
- };
264
+ });
238
265
  };
239
266
 
240
267
  /**
@@ -276,9 +303,8 @@ export const allow = () => {
276
303
  * @returns {Function} Rule function that always throws ForbiddenError
277
304
  */
278
305
  export const deny = (message = 'Access denied') => {
279
- return () => {
280
- throw new ForbiddenError(message);
281
- };
306
+ const forbidden = () => new ForbiddenError(message);
307
+ return ruleFromCheck(() => forbidden);
282
308
  };
283
309
 
284
310
  // Export all rules as an object for convenience
package/types/index.d.ts CHANGED
@@ -255,11 +255,14 @@ export const auth: {
255
255
  permissions: PermissionSchema,
256
256
  options?: AuthPluginOptions,
257
257
  ): (resolve: any, parent: any, args: any, ctx: any, info: any) => Promise<any>;
258
- /** @deprecated Use createAuthPlugin instead. Field middleware object for graphql-middleware. */
258
+ /**
259
+ * @deprecated Use createAuthPlugin instead. The same graphql-middleware function as
260
+ * createAuthMiddleware, which applies wildcard rules and the default policy to every field.
261
+ */
259
262
  createFieldMiddleware(
260
263
  permissions: PermissionSchema,
261
264
  options?: AuthPluginOptions,
262
- ): Record<string, Record<string, any>>;
265
+ ): (resolve: any, parent: any, args: any, ctx: any, info: any) => Promise<any>;
263
266
  resolvePath(obj: any, pathOrFn: string | ((obj: any) => any)): any;
264
267
  requireAuth(userPath?: string): AuthRuleFunction;
265
268
  /** Required roles must be nonempty strings; invalid configuration throws TypeError. */
@@ -269,7 +272,9 @@ export const auth: {
269
272
  permission: string | string[],
270
273
  options?: { userPath?: string; permissionsPath?: string },
271
274
  ): AuthRuleFunction;
275
+ /** Requires at least one rule function; throws TypeError otherwise. */
272
276
  composeRules(...rules: AuthRuleFunction[]): AuthRuleFunction;
277
+ /** Requires at least one rule function; throws TypeError otherwise. */
273
278
  anyRule(...rules: AuthRuleFunction[]): AuthRuleFunction;
274
279
  /** Compares nonempty string, finite number, or MongoDB ObjectId identities; missing IDs deny. */
275
280
  isOwner(ownerField?: string, userIdField?: string, options?: { userPath?: string }): AuthRuleFunction;