@simtlix/simfinity-core 3.5.1 → 3.5.3
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/README.md +8 -3
- package/package.json +1 -1
- package/src/auth/expressions.js +161 -7
- package/src/auth/index.js +168 -71
- package/src/auth/rules.js +136 -66
- package/src/auth/thenable.js +17 -0
- package/src/index.js +1 -0
- package/src/mutation-limits.js +24 -0
- package/src/relation-owners.js +5 -0
- package/src/runtime.js +293 -57
- package/src/scalars/factory.js +30 -10
- package/src/scalars/pattern.js +16 -0
- package/src/scalars.js +3 -2
- package/src/validators.js +4 -4
- package/types/index.d.ts +151 -9
package/README.md
CHANGED
|
@@ -8,15 +8,20 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
8
8
|
|
|
9
9
|
## Runtime
|
|
10
10
|
|
|
11
|
-
`createRuntime(adapter)` creates
|
|
11
|
+
`createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
|
|
12
12
|
|
|
13
13
|
- `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
|
|
14
14
|
- `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
|
|
15
15
|
- `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
|
|
16
|
-
- `preventCreatingCollection` to pass validation-only storage setup to an adapter
|
|
16
|
+
- `preventCreatingCollection` to pass validation-only storage setup to an adapter;
|
|
17
|
+
- `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
|
|
17
18
|
|
|
18
19
|
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize it.
|
|
19
20
|
|
|
21
|
+
When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
|
|
22
|
+
|
|
23
|
+
Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`).
|
|
24
|
+
|
|
20
25
|
Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
|
|
21
26
|
|
|
22
27
|
Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
|
|
@@ -27,4 +32,4 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
|
|
|
27
32
|
|
|
28
33
|
`createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
|
|
29
34
|
|
|
30
|
-
The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `QLOperator`, `QLSort`, and `QLValue`. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
|
|
35
|
+
The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
|
package/package.json
CHANGED
package/src/auth/expressions.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
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
|
+
import { isThenable, discardThenable } from './thenable.js';
|
|
9
|
+
|
|
7
10
|
const invalidResult = Symbol('invalid policy result');
|
|
8
11
|
|
|
9
12
|
const isValidRef = (refPath) => {
|
|
@@ -19,12 +22,34 @@ const isValidValue = (value) => {
|
|
|
19
22
|
if (isReference(value)) {
|
|
20
23
|
return Object.keys(value).length === 1 && Object.hasOwn(value, 'ref') && isValidRef(value.ref);
|
|
21
24
|
}
|
|
22
|
-
|
|
25
|
+
// NaN equals nothing, so a literal NaN is rejected, as in literal `in` lists.
|
|
26
|
+
return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol' && !Number.isNaN(value);
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
const isDenseArray = value => Array.isArray(value)
|
|
26
30
|
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
|
|
27
31
|
|
|
32
|
+
const isPlainValue = value => value !== null && typeof value === 'object'
|
|
33
|
+
&& (Array.isArray(value) || [Object.prototype, null].includes(Object.getPrototypeOf(value)));
|
|
34
|
+
|
|
35
|
+
// Literals are compared as they are, so a `{ ref }` nested inside one would never be resolved.
|
|
36
|
+
const containsReference = (value, seen = new Set()) => {
|
|
37
|
+
if (!isPlainValue(value) || seen.has(value)) return false;
|
|
38
|
+
if (isReference(value)) return true;
|
|
39
|
+
seen.add(value);
|
|
40
|
+
return Object.values(value).some(item => containsReference(item, seen));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isMembershipItem = value => value === null || ['string', 'boolean', 'bigint'].includes(typeof value)
|
|
44
|
+
|| (typeof value === 'number' && Number.isFinite(value)) || normalizeObjectId(value) !== undefined;
|
|
45
|
+
|
|
46
|
+
const isValidComparison = (operator, operand) => {
|
|
47
|
+
if (!isDenseArray(operand) || operand.length !== 2 || !operand.every(isValidValue)) return false;
|
|
48
|
+
if (operand.some(value => !isReference(value) && containsReference(value))) return false;
|
|
49
|
+
if (operator !== 'in' || isReference(operand[1])) return true;
|
|
50
|
+
return isDenseArray(operand[1]) && operand[1].every(isMembershipItem);
|
|
51
|
+
};
|
|
52
|
+
|
|
28
53
|
const validateExpression = (expression, ancestors = new Set()) => {
|
|
29
54
|
if (typeof expression === 'boolean') return true;
|
|
30
55
|
if (expression === null || typeof expression !== 'object' || Array.isArray(expression)) return false;
|
|
@@ -38,8 +63,7 @@ const validateExpression = (expression, ancestors = new Set()) => {
|
|
|
38
63
|
switch (operator) {
|
|
39
64
|
case 'eq':
|
|
40
65
|
case 'in':
|
|
41
|
-
return
|
|
42
|
-
&& (operator !== 'in' || Array.isArray(operand[1]) || isReference(operand[1]));
|
|
66
|
+
return isValidComparison(operator, operand);
|
|
43
67
|
case 'allOf':
|
|
44
68
|
case 'anyOf':
|
|
45
69
|
return isDenseArray(operand) && operand.every(expr => validateExpression(expr, ancestors));
|
|
@@ -65,19 +89,111 @@ const resolveRef = (refPath, context) => {
|
|
|
65
89
|
return value;
|
|
66
90
|
};
|
|
67
91
|
|
|
92
|
+
/**
|
|
93
|
+
* An operand of a created rule. The literal or reference classification is made once, when the
|
|
94
|
+
* rule is created, so mutating a literal object afterwards cannot turn it into a reference.
|
|
95
|
+
*/
|
|
96
|
+
class CompiledOperand {
|
|
97
|
+
constructor(reference, value) {
|
|
98
|
+
this.reference = reference;
|
|
99
|
+
this.value = value;
|
|
100
|
+
Object.freeze(this);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
/** Resolve a literal or reference without coercing its value. */
|
|
69
105
|
const resolveValue = (value, context) => {
|
|
106
|
+
if (value instanceof CompiledOperand) return value.reference ? resolveRef(value.value, context) : value.value;
|
|
70
107
|
return isReference(value) ? resolveRef(value.ref, context) : value;
|
|
71
108
|
};
|
|
72
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The time value of a genuine Date from any realm (NaN when invalid), or undefined for other values.
|
|
112
|
+
* The tag skips other objects cheaply; getTime then rejects objects that only spoof the tag.
|
|
113
|
+
*/
|
|
114
|
+
const dateTime = (value) => {
|
|
115
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
116
|
+
try {
|
|
117
|
+
return Object.prototype.toString.call(value) === '[object Date]' ? Date.prototype.getTime.call(value) : undefined;
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Registered ObjectIds compare by hexadecimal value; other values except Dates compare strictly.
|
|
124
|
+
const comparable = value => normalizeObjectId(value) ?? value;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify the left value of a comparison once: a promise, null, NaN, a Date by its time, or another
|
|
128
|
+
* value in its comparable form. A membership list then repeats only the checks on its items.
|
|
129
|
+
*/
|
|
130
|
+
const classifyOperand = (value) => {
|
|
131
|
+
if (isThenable(value)) return { kind: 'promise', value: undefined };
|
|
132
|
+
if (value === null) return { kind: 'null', value };
|
|
133
|
+
if (Number.isNaN(value)) return { kind: 'nan', value };
|
|
134
|
+
const time = dateTime(value);
|
|
135
|
+
if (time !== undefined) return { kind: 'date', value: time };
|
|
136
|
+
return { kind: 'value', value: comparable(value) };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compare a classified value with a resolved value: true, false, or invalidResult when they cannot
|
|
141
|
+
* be compared reliably. A promise is not its eventual value, so it is never compared; a rejected
|
|
142
|
+
* native one is handled, so it cannot crash the process. null stays comparable with anything else,
|
|
143
|
+
* as an explicit check for a missing value. NaN equals nothing, and Dates compare by time, only with
|
|
144
|
+
* valid Dates. Otherwise strict equality never matches values of different types, such as a string
|
|
145
|
+
* and a number. Such comparisons are invalid, so `not` cannot turn them into a grant.
|
|
146
|
+
*/
|
|
147
|
+
const compareOperand = (left, right) => {
|
|
148
|
+
if (isThenable(right)) {
|
|
149
|
+
discardThenable(right);
|
|
150
|
+
return invalidResult;
|
|
151
|
+
}
|
|
152
|
+
if (left.kind === 'promise') return invalidResult;
|
|
153
|
+
if (left.kind === 'null' || right === null) return left.kind === 'null' && right === null;
|
|
154
|
+
if (left.kind === 'nan' || Number.isNaN(right)) return invalidResult;
|
|
155
|
+
const rightTime = dateTime(right);
|
|
156
|
+
if (left.kind === 'date' || rightTime !== undefined) {
|
|
157
|
+
if (left.kind !== 'date' || rightTime === undefined || Number.isNaN(left.value) || Number.isNaN(rightTime)) {
|
|
158
|
+
return invalidResult;
|
|
159
|
+
}
|
|
160
|
+
return left.value === rightTime;
|
|
161
|
+
}
|
|
162
|
+
const other = comparable(right);
|
|
163
|
+
return typeof left.value !== typeof other ? invalidResult : left.value === other;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const evaluateMembership = (target, list) => {
|
|
167
|
+
// A list or plain object is not a member value.
|
|
168
|
+
if (!Array.isArray(list) || isPlainValue(target)) return invalidResult;
|
|
169
|
+
const operand = classifyOperand(target);
|
|
170
|
+
// Never call the list's own includes: Mongoose arrays cast and compare loosely.
|
|
171
|
+
let mismatched = false;
|
|
172
|
+
for (let index = 0; index < list.length; index++) {
|
|
173
|
+
const result = compareOperand(operand, list[index]);
|
|
174
|
+
if (result === true) return true;
|
|
175
|
+
if (result === invalidResult) mismatched = true;
|
|
176
|
+
}
|
|
177
|
+
// Without a match, an item that could not be compared means the list was not checked reliably.
|
|
178
|
+
return mismatched ? invalidResult : false;
|
|
179
|
+
};
|
|
180
|
+
|
|
73
181
|
const evaluateComparison = (operator, operands, context) => {
|
|
74
182
|
const left = resolveValue(operands[0], context);
|
|
75
183
|
const right = resolveValue(operands[1], context);
|
|
76
|
-
|
|
184
|
+
// A promise is not its eventual value, so it cannot be compared, even with an empty list. A rejected
|
|
185
|
+
// native promise a ref yields, such as a lazy relation that failed to load, is handled so it cannot
|
|
186
|
+
// crash the process.
|
|
187
|
+
const leftThenable = isThenable(left);
|
|
188
|
+
const rightThenable = isThenable(right);
|
|
189
|
+
if (leftThenable) discardThenable(left);
|
|
190
|
+
if (rightThenable) discardThenable(right);
|
|
191
|
+
if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function'
|
|
192
|
+
|| leftThenable || rightThenable) {
|
|
77
193
|
return invalidResult;
|
|
78
194
|
}
|
|
79
|
-
if (operator === '
|
|
80
|
-
return
|
|
195
|
+
if (operator === 'in') return evaluateMembership(left, right);
|
|
196
|
+
return compareOperand(classifyOperand(left), right);
|
|
81
197
|
};
|
|
82
198
|
|
|
83
199
|
const evaluateAllOf = (expressions, context) => {
|
|
@@ -140,12 +256,50 @@ export const evaluateExpression = (expression, context) => {
|
|
|
140
256
|
/** Check the complete AST, including boolean literals and every nested branch. */
|
|
141
257
|
export const isPolicyExpression = value => validateExpression(value);
|
|
142
258
|
|
|
259
|
+
// Copy by index with built-in methods only, so an Array subclass cannot alter the copy.
|
|
260
|
+
const copyArray = (array, transform = value => value) => Array.from(
|
|
261
|
+
{ length: array.length },
|
|
262
|
+
(_, index) => transform(array[index], index),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const compileOperand = (operator, value, index) => {
|
|
266
|
+
if (isReference(value)) return new CompiledOperand(true, value.ref);
|
|
267
|
+
// A literal membership list is copied, so only its validated items are compared.
|
|
268
|
+
if (operator === 'in' && index === 1) return new CompiledOperand(false, Object.freeze(copyArray(value)));
|
|
269
|
+
// A literal Date compares by time, so a private copy keeps setTime on the configured one out.
|
|
270
|
+
const time = dateTime(value);
|
|
271
|
+
if (time !== undefined) return new CompiledOperand(false, new Date(time));
|
|
272
|
+
// Other literals keep their identity, which equality compares.
|
|
273
|
+
return new CompiledOperand(false, value);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const snapshotOperands = (operator, operand) => Object.freeze(
|
|
277
|
+
copyArray(operand, (value, index) => compileOperand(operator, value, index)),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Copy the validated structure, so evaluation needs no revalidation and later changes to the
|
|
282
|
+
* configured object cannot make it malformed. Date literals are copied; other literal operands keep
|
|
283
|
+
* their identity.
|
|
284
|
+
*/
|
|
285
|
+
const snapshotExpression = (expression) => {
|
|
286
|
+
if (typeof expression === 'boolean') return expression;
|
|
287
|
+
const snapshot = {};
|
|
288
|
+
for (const [operator, operand] of Object.entries(expression)) {
|
|
289
|
+
if (operator === 'not') snapshot[operator] = snapshotExpression(operand);
|
|
290
|
+
else if (operator === 'eq' || operator === 'in') snapshot[operator] = snapshotOperands(operator, operand);
|
|
291
|
+
else snapshot[operator] = Object.freeze(copyArray(operand, snapshotExpression));
|
|
292
|
+
}
|
|
293
|
+
return Object.freeze(snapshot);
|
|
294
|
+
};
|
|
295
|
+
|
|
143
296
|
/** Create a rule, rejecting malformed policies at configuration time. */
|
|
144
297
|
export const createRuleFromExpression = (expression) => {
|
|
145
298
|
if (!isPolicyExpression(expression)) {
|
|
146
299
|
throw new TypeError('Invalid authorization policy expression');
|
|
147
300
|
}
|
|
148
|
-
|
|
301
|
+
const snapshot = snapshotExpression(expression);
|
|
302
|
+
return (parent, args, ctx) => evaluateValidatedExpression(snapshot, { parent, args, ctx }) === true;
|
|
149
303
|
};
|
|
150
304
|
|
|
151
305
|
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,11 +31,23 @@
|
|
|
32
31
|
* const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
33
32
|
*/
|
|
34
33
|
|
|
35
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
GraphQLError,
|
|
36
|
+
GraphQLEnumType,
|
|
37
|
+
GraphQLInputObjectType,
|
|
38
|
+
GraphQLInterfaceType,
|
|
39
|
+
GraphQLObjectType,
|
|
40
|
+
GraphQLScalarType,
|
|
41
|
+
GraphQLUnionType,
|
|
42
|
+
defaultFieldResolver,
|
|
43
|
+
__Field,
|
|
44
|
+
} from 'graphql';
|
|
36
45
|
import SimfinityError from '../errors/simfinity.error.js';
|
|
37
46
|
import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
|
|
38
47
|
import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
|
|
39
48
|
import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
|
|
49
|
+
import { relationFieldOwners } from '../relation-owners.js';
|
|
50
|
+
import { isThenable } from './thenable.js';
|
|
40
51
|
import {
|
|
41
52
|
resolvePath,
|
|
42
53
|
requireAuth,
|
|
@@ -128,6 +139,39 @@ const normalizeRule = (rule) => {
|
|
|
128
139
|
const isPermissionMap = value => value !== null && typeof value === 'object'
|
|
129
140
|
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
130
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Simfinity adds an `extensions` field to introspection's `__Field`. Its object types are shared by
|
|
144
|
+
* every schema in the process and describe the schema itself, so authorization skips them like
|
|
145
|
+
* `__` types unless the permission map names them. They are matched by identity.
|
|
146
|
+
*/
|
|
147
|
+
const isSimfinityMetadataType = (type) => {
|
|
148
|
+
const extensionsType = __Field.getFields().extensions?.type;
|
|
149
|
+
if (!(extensionsType instanceof GraphQLObjectType)) return false;
|
|
150
|
+
return type === extensionsType || type === extensionsType.getFields().relation?.type;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const isExemptMetadataType = (permissions, type) => isSimfinityMetadataType(type)
|
|
154
|
+
&& !Object.hasOwn(permissions, type.name);
|
|
155
|
+
|
|
156
|
+
// Schemas processed by any auth plugin instance. A wrapper defers to the other instances only in
|
|
157
|
+
// these schemas; in a schema no instance processed, every wrapper keeps enforcing its rules.
|
|
158
|
+
const authorizedSchemas = new WeakSet();
|
|
159
|
+
|
|
160
|
+
const namedTypeClasses = [
|
|
161
|
+
GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType,
|
|
162
|
+
GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType,
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A type created by another copy of graphql (a second installed version, or its ESM and CommonJS
|
|
167
|
+
* builds loaded together) fails `instanceof`, so it cannot be classified and its fields could not be
|
|
168
|
+
* protected. graphql's own isObjectType only reports such copies outside production.
|
|
169
|
+
*/
|
|
170
|
+
const assertSameGraphQLModule = (typeName, type) => {
|
|
171
|
+
if (namedTypeClasses.some(NamedType => type instanceof NamedType)) return;
|
|
172
|
+
throw new TypeError(`Cannot authorize type ${typeName}: it was created by a different copy of the graphql module than the auth plugin uses. Install a single graphql version and load it through one entry point.`);
|
|
173
|
+
};
|
|
174
|
+
|
|
131
175
|
const validateConfiguration = (permissions, defaultPolicy) => {
|
|
132
176
|
if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
|
|
133
177
|
throw new TypeError('defaultPolicy must be ALLOW or DENY');
|
|
@@ -262,6 +306,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
|
|
|
262
306
|
* Returns a middleware object keyed by type name, each containing field resolvers
|
|
263
307
|
*/
|
|
264
308
|
return async (resolve, parent, args, ctx, info) => {
|
|
309
|
+
if (isExemptMetadataType(permissions, info.parentType)) return resolve(parent, args, ctx, info);
|
|
265
310
|
const typeName = info.parentType.name;
|
|
266
311
|
const fieldName = info.fieldName;
|
|
267
312
|
|
|
@@ -305,35 +350,20 @@ export const createAuthMiddleware = (permissions, options = {}) => {
|
|
|
305
350
|
};
|
|
306
351
|
|
|
307
352
|
/**
|
|
308
|
-
* Creates
|
|
309
|
-
*
|
|
353
|
+
* Creates graphql-middleware authorization for every object field of a schema.
|
|
354
|
+
*
|
|
355
|
+
* It returns the same function as {@link createAuthMiddleware}. graphql-middleware applies a
|
|
356
|
+
* function middleware to every field, so `'*'` rules and the default policy also apply to fields
|
|
357
|
+
* the permission map does not name. A map of only the named fields would leave the others open.
|
|
310
358
|
*
|
|
311
359
|
* @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
|
|
312
360
|
* can cause duplicate-type errors when the schema contains custom introspection extensions.
|
|
313
361
|
*
|
|
314
362
|
* @param {PermissionSchema} permissions - The permission schema
|
|
315
363
|
* @param {AuthMiddlewareOptions} [options={}] - Middleware options
|
|
316
|
-
* @returns {
|
|
364
|
+
* @returns {Function} A graphql-middleware compatible middleware function
|
|
317
365
|
*/
|
|
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
|
-
};
|
|
366
|
+
export const createFieldMiddleware = (permissions, options = {}) => createAuthMiddleware(permissions, options);
|
|
337
367
|
|
|
338
368
|
/**
|
|
339
369
|
* Creates an Envelop-compatible authorization plugin that wraps schema resolvers in-place.
|
|
@@ -342,26 +372,33 @@ export const createFieldMiddleware = (permissions, options = {}) => {
|
|
|
342
372
|
* and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
|
|
343
373
|
* avoiding schema reconstruction and the duplicate-type errors it can cause.
|
|
344
374
|
*
|
|
375
|
+
* `onSchemaChange` throws `TypeError` before wrapping any field when a schema type was created by
|
|
376
|
+
* another copy of the graphql module, instead of leaving that type's fields unprotected.
|
|
377
|
+
*
|
|
345
378
|
* @param {PermissionSchema} permissions - The permission schema object
|
|
346
379
|
* @param {AuthMiddlewareOptions} [options={}] - Plugin options
|
|
347
380
|
* @returns {Object} An Envelop plugin with an `onSchemaChange` hook
|
|
348
381
|
*
|
|
349
382
|
* @example
|
|
350
|
-
* import {
|
|
383
|
+
* import { createAuthPlugin, requireAuth, requireRole } from '@simtlix/simfinity-core/auth';
|
|
351
384
|
* import { createYoga } from 'graphql-yoga';
|
|
352
385
|
*
|
|
386
|
+
* // Keys are the schema's type names: Simfinity names its query root `RootQueryType`.
|
|
353
387
|
* const permissions = {
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
388
|
+
* RootQueryType: {
|
|
389
|
+
* series: requireAuth(),
|
|
390
|
+
* serie: requireAuth(),
|
|
357
391
|
* },
|
|
358
|
-
*
|
|
392
|
+
* Mutation: {
|
|
393
|
+
* deleteserie: requireRole('ADMIN'),
|
|
394
|
+
* },
|
|
395
|
+
* serie: {
|
|
359
396
|
* '*': requireAuth(),
|
|
360
|
-
*
|
|
361
|
-
* }
|
|
397
|
+
* budget: requireRole('ADMIN'),
|
|
398
|
+
* },
|
|
362
399
|
* };
|
|
363
400
|
*
|
|
364
|
-
* const authPlugin =
|
|
401
|
+
* const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
|
|
365
402
|
* const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
366
403
|
*/
|
|
367
404
|
export const createAuthPlugin = (permissions, options = {}) => {
|
|
@@ -374,57 +411,117 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
374
411
|
|
|
375
412
|
const log = debug ? console.log.bind(console, '[auth]') : () => {};
|
|
376
413
|
const processedSchemas = new WeakSet();
|
|
414
|
+
// Schemas built from the same types share their field objects, so each field is wrapped once.
|
|
415
|
+
const wrappers = new WeakMap();
|
|
416
|
+
let warnedAboutQueryRoot = false;
|
|
417
|
+
|
|
418
|
+
const warnAboutQueryRoot = (schema) => {
|
|
419
|
+
if (warnedAboutQueryRoot || !Object.hasOwn(permissions, 'Query') || schema.getType('Query')) return;
|
|
420
|
+
const queryRoot = schema.getQueryType()?.name;
|
|
421
|
+
if (!queryRoot) return;
|
|
422
|
+
warnedAboutQueryRoot = true;
|
|
423
|
+
console.warn(`[auth] Permissions for type "Query" match no type in the schema. Its query root is named "${queryRoot}"; did you mean "${queryRoot}"?`);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const createFieldResolver = (schema, typeName, fieldName, rules, query, originalResolve) => {
|
|
427
|
+
const key = `${typeName}.${fieldName}`;
|
|
428
|
+
const denied = () => Promise.reject(new ForbiddenError(`Access denied to ${key}`));
|
|
429
|
+
|
|
430
|
+
// Errors keep reaching GraphQL as rejections, as they did when the whole check was async.
|
|
431
|
+
const resolveField = (parent, args, ctx, info) => {
|
|
432
|
+
try {
|
|
433
|
+
return originalResolve(parent, args, ctx, info);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return Promise.reject(error);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const grant = (parent, args, ctx, info) => {
|
|
440
|
+
if (!query) return resolveField(parent, args, ctx, info);
|
|
441
|
+
return authorizeQueryPaths(permissions, defaultPolicy, info?.schema ?? schema, query, args, ctx, info)
|
|
442
|
+
.then(() => resolveField(parent, args, ctx, info));
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Rules run in order and synchronously until one returns a promise.
|
|
446
|
+
const runRules = (index, parent, args, ctx, info) => {
|
|
447
|
+
for (let current = index; current < rules.length; current++) {
|
|
448
|
+
if (debug) log(`Executing rule for ${key}`);
|
|
449
|
+
let result;
|
|
450
|
+
try {
|
|
451
|
+
result = rules[current](parent, args, ctx, info);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return Promise.reject(error);
|
|
454
|
+
}
|
|
455
|
+
if (isThenable(result)) {
|
|
456
|
+
return Promise.resolve(result).then(value => (
|
|
457
|
+
value === true || value === undefined ? runRules(current + 1, parent, args, ctx, info) : deny()
|
|
458
|
+
));
|
|
459
|
+
}
|
|
460
|
+
if (result !== true && result !== undefined) return deny();
|
|
461
|
+
}
|
|
462
|
+
if (debug) log(`Access granted to ${key}`);
|
|
463
|
+
return grant(parent, args, ctx, info);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const deny = () => {
|
|
467
|
+
if (debug) log(`Rule denied access to ${key}`);
|
|
468
|
+
return denied();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const resolver = (parent, args, ctx, info) => {
|
|
472
|
+
// In a schema that only other auth plugin instances processed, their rules apply, not these.
|
|
473
|
+
if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
|
|
474
|
+
return originalResolve(parent, args, ctx, info);
|
|
475
|
+
}
|
|
476
|
+
if (debug) log(`Checking ${key}`);
|
|
477
|
+
|
|
478
|
+
if (rules === null) {
|
|
479
|
+
if (debug) log(`No rules for ${key}, applying default policy: ${defaultPolicy}`);
|
|
480
|
+
return defaultPolicy === 'DENY' ? denied() : grant(parent, args, ctx, info);
|
|
481
|
+
}
|
|
482
|
+
return runRules(0, parent, args, ctx, info);
|
|
483
|
+
};
|
|
484
|
+
// The wrapper still reads through the runtime that generated a relation resolver, so a field
|
|
485
|
+
// copied with it stays bound to that runtime.
|
|
486
|
+
const owner = relationFieldOwners.get(originalResolve);
|
|
487
|
+
if (owner !== undefined) relationFieldOwners.set(resolver, owner);
|
|
488
|
+
return resolver;
|
|
489
|
+
};
|
|
377
490
|
|
|
378
491
|
const wrapSchemaResolvers = (schema) => {
|
|
379
492
|
if (processedSchemas.has(schema)) return;
|
|
380
493
|
validateConfiguration(permissions, defaultPolicy);
|
|
494
|
+
warnAboutQueryRoot(schema);
|
|
495
|
+
|
|
496
|
+
// Check every type before wrapping any field, so an unsupported schema is left unchanged.
|
|
497
|
+
const objectTypes = [];
|
|
498
|
+
for (const [typeName, type] of Object.entries(schema.getTypeMap())) {
|
|
499
|
+
if (typeName.startsWith('__')) continue;
|
|
500
|
+
assertSameGraphQLModule(typeName, type);
|
|
501
|
+
if (type instanceof GraphQLObjectType && !isExemptMetadataType(permissions, type)) objectTypes.push([typeName, type]);
|
|
502
|
+
}
|
|
381
503
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
for (const [typeName, type] of Object.entries(typeMap)) {
|
|
385
|
-
if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__')) continue;
|
|
386
|
-
|
|
504
|
+
for (const [typeName, type] of objectTypes) {
|
|
387
505
|
const fields = type.getFields();
|
|
388
506
|
|
|
389
507
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
508
|
+
// Skip fields this plugin already wraps; a resolver replaced since then is wrapped again.
|
|
509
|
+
const wrapper = wrappers.get(field);
|
|
510
|
+
if (wrapper !== undefined && wrapper === field.resolve) continue;
|
|
511
|
+
|
|
390
512
|
const rules = getFieldRules(permissions, typeName, fieldName);
|
|
391
|
-
const originalResolve = field.resolve || defaultFieldResolver;
|
|
392
513
|
const query = field.extensions?.simfinityQuery;
|
|
514
|
+
// An allowed field with no rule and no client query paths has nothing to check.
|
|
515
|
+
if (rules === null && defaultPolicy === 'ALLOW' && !query && !debug) continue;
|
|
393
516
|
|
|
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
|
-
};
|
|
517
|
+
const resolver = createFieldResolver(schema, typeName, fieldName, rules, query, field.resolve || defaultFieldResolver);
|
|
518
|
+
field.resolve = resolver;
|
|
519
|
+
wrappers.set(field, resolver);
|
|
424
520
|
}
|
|
425
521
|
}
|
|
426
522
|
|
|
427
523
|
processedSchemas.add(schema);
|
|
524
|
+
authorizedSchemas.add(schema);
|
|
428
525
|
};
|
|
429
526
|
|
|
430
527
|
return {
|