@simtlix/simfinity-core 3.5.1 → 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 +1 -1
- package/src/auth/expressions.js +98 -5
- package/src/auth/index.js +128 -67
- package/src/auth/rules.js +84 -58
- package/types/index.d.ts +7 -2
package/package.json
CHANGED
package/src/auth/expressions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 {
|
|
12
|
+
* import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
|
|
13
13
|
* import { createYoga } from 'graphql-yoga';
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* // Simfinity names its roots `RootQueryType` and `Mutation`.
|
|
17
16
|
* const permissions = {
|
|
18
|
-
*
|
|
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
|
|
309
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
361
|
+
* RootQueryType: {
|
|
362
|
+
* series: requireAuth(),
|
|
363
|
+
* serie: requireAuth(),
|
|
357
364
|
* },
|
|
358
|
-
*
|
|
365
|
+
* Mutation: {
|
|
366
|
+
* deleteserie: requireRole('ADMIN'),
|
|
367
|
+
* },
|
|
368
|
+
* serie: {
|
|
359
369
|
* '*': requireAuth(),
|
|
360
|
-
*
|
|
361
|
-
* }
|
|
370
|
+
* budget: requireRole('ADMIN'),
|
|
371
|
+
* },
|
|
362
372
|
* };
|
|
363
373
|
*
|
|
364
|
-
* const authPlugin =
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
if (
|
|
121
|
-
|
|
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
|
-
//
|
|
126
|
-
if (
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
/**
|
|
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
|
-
):
|
|
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;
|