@simtlix/simfinity-core 3.2.0

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Simfinity GraphQL Authorization
3
+ *
4
+ * Production-grade centralized GraphQL authorization supporting:
5
+ * - RBAC / ABAC
6
+ * - Function-based rules
7
+ * - Declarative policy expressions (JSON AST)
8
+ * - Wildcard "*" permissions
9
+ * - Default allow/deny policies
10
+ *
11
+ * @example
12
+ * import { auth } from '@simtlix/simfinity-js';
13
+ * import { createYoga } from 'graphql-yoga';
14
+ *
15
+ * const { createAuthPlugin, requireAuth, requireRole } = auth;
16
+ *
17
+ * const permissions = {
18
+ * Query: {
19
+ * series: requireAuth(),
20
+ * seasons: requireAuth(),
21
+ * },
22
+ * Mutation: {
23
+ * deleteserie: requireRole('admin'),
24
+ * deletestar: requireRole('admin'),
25
+ * },
26
+ * serie: {
27
+ * '*': requireAuth(),
28
+ * }
29
+ * };
30
+ *
31
+ * const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
32
+ * const yoga = createYoga({ schema, plugins: [authPlugin] });
33
+ */
34
+
35
+ import { GraphQLObjectType, defaultFieldResolver } from 'graphql';
36
+ import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
37
+ import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
38
+ import {
39
+ resolvePath,
40
+ requireAuth,
41
+ requireRole,
42
+ requirePermission,
43
+ composeRules,
44
+ anyRule,
45
+ isOwner,
46
+ createRule,
47
+ allow,
48
+ deny,
49
+ } from './rules.js';
50
+
51
+ // Re-export shared errors
52
+ export { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
53
+
54
+ // Re-export expression utilities
55
+ export { evaluateExpression, isPolicyExpression, createRuleFromExpression } from './expressions.js';
56
+
57
+ // Re-export rule helpers and utilities
58
+ export {
59
+ resolvePath,
60
+ requireAuth,
61
+ requireRole,
62
+ requirePermission,
63
+ composeRules,
64
+ anyRule,
65
+ isOwner,
66
+ createRule,
67
+ allow,
68
+ deny,
69
+ } from './rules.js';
70
+
71
+ /**
72
+ * @typedef {'ALLOW' | 'DENY'} DefaultPolicy
73
+ */
74
+
75
+ /**
76
+ * @typedef {Object} AuthMiddlewareOptions
77
+ * @property {DefaultPolicy} [defaultPolicy='DENY'] - Default policy when no rule matches
78
+ * @property {boolean} [debug=false] - Enable debug logging
79
+ */
80
+
81
+ /**
82
+ * @typedef {Function} RuleFunction
83
+ * @param {*} parent - Parent resolver result
84
+ * @param {Object} args - GraphQL arguments
85
+ * @param {Object} ctx - GraphQL context
86
+ * @param {Object} info - GraphQL resolve info
87
+ * @returns {boolean|void|Promise<boolean|void>} - true/void to allow, false to deny
88
+ */
89
+
90
+ /**
91
+ * @typedef {boolean|Object|RuleFunction|Array<Rule>} Rule
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object.<string, Rule>} TypePermissions
96
+ */
97
+
98
+ /**
99
+ * @typedef {Object.<string, TypePermissions>} PermissionSchema
100
+ */
101
+
102
+ /**
103
+ * Normalizes a rule to always be an array of functions
104
+ * @param {Rule} rule - The rule to normalize
105
+ * @returns {Function[]} Array of rule functions
106
+ */
107
+ const normalizeRule = (rule) => {
108
+ if (typeof rule === 'function') {
109
+ return [rule];
110
+ }
111
+
112
+ if (Array.isArray(rule)) {
113
+ if (rule.length === 0 || Array.from(rule).some(r => r === undefined)) {
114
+ throw new TypeError('Authorization rule arrays must contain valid rules');
115
+ }
116
+ return rule.flatMap(r => normalizeRule(r));
117
+ }
118
+
119
+ if (isPolicyExpression(rule)) {
120
+ return [createRuleFromExpression(rule)];
121
+ }
122
+
123
+ throw new TypeError('Invalid authorization rule: expected a function, nonempty rule array, or policy expression');
124
+ };
125
+
126
+ const isPermissionMap = value => value !== null && typeof value === 'object'
127
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
128
+
129
+ const validateConfiguration = (permissions, defaultPolicy) => {
130
+ if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
131
+ throw new TypeError('defaultPolicy must be ALLOW or DENY');
132
+ }
133
+ if (!isPermissionMap(permissions)) {
134
+ throw new TypeError('Authorization permissions must be an object');
135
+ }
136
+ for (const [typeName, typePermissions] of Object.entries(permissions)) {
137
+ if (!isPermissionMap(typePermissions)) {
138
+ throw new TypeError(`Permissions for ${typeName} must be an object`);
139
+ }
140
+ for (const [fieldName, rule] of Object.entries(typePermissions)) {
141
+ try {
142
+ normalizeRule(rule);
143
+ } catch (error) {
144
+ throw new TypeError(`Invalid authorization rule for ${typeName}.${fieldName}: ${error.message}`);
145
+ }
146
+ }
147
+ }
148
+ };
149
+
150
+ /**
151
+ * Gets the rule for a specific field, with wildcard fallback
152
+ * @param {PermissionSchema} permissions - The permission schema
153
+ * @param {string} typeName - The GraphQL type name
154
+ * @param {string} fieldName - The field name
155
+ * @returns {Function[]|null} Array of rule functions or null if no rule found
156
+ */
157
+ const getFieldRules = (permissions, typeName, fieldName) => {
158
+ if (!Object.hasOwn(permissions, typeName)) return null;
159
+ const typePerms = permissions[typeName];
160
+ if (!isPermissionMap(typePerms)) {
161
+ throw new TypeError(`Permissions for ${typeName} must be an object`);
162
+ }
163
+
164
+ // Check for exact field rule first
165
+ if (Object.hasOwn(typePerms, fieldName)) {
166
+ return normalizeRule(typePerms[fieldName]);
167
+ }
168
+
169
+ // Fallback to wildcard
170
+ if (Object.hasOwn(typePerms, '*')) {
171
+ return normalizeRule(typePerms['*']);
172
+ }
173
+
174
+ return null;
175
+ };
176
+
177
+ /**
178
+ * Executes a single rule
179
+ * @param {Function} rule - The rule function to execute
180
+ * @param {*} parent - Parent resolver result
181
+ * @param {Object} args - GraphQL arguments
182
+ * @param {Object} ctx - GraphQL context
183
+ * @param {Object} info - GraphQL resolve info
184
+ * @returns {Promise<boolean>} True if allowed, false if denied
185
+ */
186
+ const executeRule = async (rule, parent, args, ctx, info) => {
187
+ const result = await rule(parent, args, ctx, info);
188
+
189
+ // void/undefined/true means allow
190
+ if (result === undefined || result === true) {
191
+ return true;
192
+ }
193
+
194
+ // false means deny
195
+ return false;
196
+ };
197
+
198
+ /**
199
+ * Creates a graphql-middleware compatible authorization middleware.
200
+ *
201
+ * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
202
+ * can cause duplicate-type errors when the schema contains custom introspection extensions.
203
+ *
204
+ * @param {PermissionSchema} permissions - The permission schema object
205
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
206
+ * @returns {Function} A graphql-middleware compatible middleware function
207
+ */
208
+ export const createAuthMiddleware = (permissions, options = {}) => {
209
+ const {
210
+ defaultPolicy = 'DENY',
211
+ debug = false,
212
+ } = options;
213
+
214
+ validateConfiguration(permissions, defaultPolicy);
215
+
216
+ const log = debug ? console.log.bind(console, '[auth]') : () => {};
217
+
218
+ /**
219
+ * The middleware generator function
220
+ * Returns a middleware object keyed by type name, each containing field resolvers
221
+ */
222
+ return async (resolve, parent, args, ctx, info) => {
223
+ const typeName = info.parentType.name;
224
+ const fieldName = info.fieldName;
225
+
226
+ log(`Checking ${typeName}.${fieldName}`);
227
+
228
+ // Get rules for this field
229
+ const rules = getFieldRules(permissions, typeName, fieldName);
230
+
231
+ // If no rules found, apply default policy
232
+ if (rules === null) {
233
+ log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
234
+
235
+ if (defaultPolicy === 'DENY') {
236
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
237
+ }
238
+
239
+ // ALLOW - proceed to resolver
240
+ return resolve(parent, args, ctx, info);
241
+ }
242
+
243
+ // Execute all rules (AND logic - all must pass)
244
+ for (const rule of rules) {
245
+ log(`Executing rule for ${typeName}.${fieldName}`);
246
+
247
+ const allowed = await executeRule(rule, parent, args, ctx, info);
248
+
249
+ if (!allowed) {
250
+ log(`Rule denied access to ${typeName}.${fieldName}`);
251
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
252
+ }
253
+ }
254
+
255
+ log(`Access granted to ${typeName}.${fieldName}`);
256
+
257
+ // All rules passed - proceed to resolver
258
+ return resolve(parent, args, ctx, info);
259
+ };
260
+ };
261
+
262
+ /**
263
+ * Creates a field-level middleware object from a permission schema.
264
+ * This can be used with graphql-middleware's applyMiddleware.
265
+ *
266
+ * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
267
+ * can cause duplicate-type errors when the schema contains custom introspection extensions.
268
+ *
269
+ * @param {PermissionSchema} permissions - The permission schema
270
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
271
+ * @returns {Object} Field middleware object compatible with graphql-middleware
272
+ */
273
+ export const createFieldMiddleware = (permissions, options = {}) => {
274
+ const middleware = createAuthMiddleware(permissions, options);
275
+ const fieldMiddleware = {};
276
+
277
+ for (const typeName of Object.keys(permissions)) {
278
+ fieldMiddleware[typeName] = {};
279
+
280
+ const typePerms = permissions[typeName];
281
+ for (const fieldName of Object.keys(typePerms)) {
282
+ if (fieldName === '*') {
283
+ // Wildcard rules are handled by the middleware internally
284
+ continue;
285
+ }
286
+ fieldMiddleware[typeName][fieldName] = middleware;
287
+ }
288
+ }
289
+
290
+ return fieldMiddleware;
291
+ };
292
+
293
+ /**
294
+ * Creates an Envelop-compatible authorization plugin that wraps schema resolvers in-place.
295
+ *
296
+ * Unlike {@link createAuthMiddleware} (which requires graphql-middleware's `applyMiddleware`
297
+ * and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
298
+ * avoiding schema reconstruction and the duplicate-type errors it can cause.
299
+ *
300
+ * @param {PermissionSchema} permissions - The permission schema object
301
+ * @param {AuthMiddlewareOptions} [options={}] - Plugin options
302
+ * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
303
+ *
304
+ * @example
305
+ * import { auth } from '@simtlix/simfinity-js';
306
+ * import { createYoga } from 'graphql-yoga';
307
+ *
308
+ * const permissions = {
309
+ * Query: {
310
+ * users: requireAuth(),
311
+ * adminDashboard: requireRole('ADMIN')
312
+ * },
313
+ * User: {
314
+ * '*': requireAuth(),
315
+ * email: requireRole('ADMIN')
316
+ * }
317
+ * };
318
+ *
319
+ * const authPlugin = auth.createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
320
+ * const yoga = createYoga({ schema, plugins: [authPlugin] });
321
+ */
322
+ export const createAuthPlugin = (permissions, options = {}) => {
323
+ const {
324
+ defaultPolicy = 'DENY',
325
+ debug = false,
326
+ } = options;
327
+
328
+ validateConfiguration(permissions, defaultPolicy);
329
+
330
+ const log = debug ? console.log.bind(console, '[auth]') : () => {};
331
+ const processedSchemas = new WeakSet();
332
+
333
+ const wrapSchemaResolvers = (schema) => {
334
+ if (processedSchemas.has(schema)) return;
335
+ validateConfiguration(permissions, defaultPolicy);
336
+
337
+ const typeMap = schema.getTypeMap();
338
+
339
+ for (const [typeName, type] of Object.entries(typeMap)) {
340
+ if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__')) continue;
341
+
342
+ const fields = type.getFields();
343
+
344
+ for (const [fieldName, field] of Object.entries(fields)) {
345
+ const rules = getFieldRules(permissions, typeName, fieldName);
346
+ const originalResolve = field.resolve || defaultFieldResolver;
347
+
348
+ field.resolve = async (parent, args, ctx, info) => {
349
+ log(`Checking ${typeName}.${fieldName}`);
350
+
351
+ if (rules === null) {
352
+ log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
353
+
354
+ if (defaultPolicy === 'DENY') {
355
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
356
+ }
357
+
358
+ return originalResolve(parent, args, ctx, info);
359
+ }
360
+
361
+ for (const rule of rules) {
362
+ log(`Executing rule for ${typeName}.${fieldName}`);
363
+
364
+ const allowed = await executeRule(rule, parent, args, ctx, info);
365
+
366
+ if (!allowed) {
367
+ log(`Rule denied access to ${typeName}.${fieldName}`);
368
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
369
+ }
370
+ }
371
+
372
+ log(`Access granted to ${typeName}.${fieldName}`);
373
+
374
+ return originalResolve(parent, args, ctx, info);
375
+ };
376
+ }
377
+ }
378
+
379
+ processedSchemas.add(schema);
380
+ };
381
+
382
+ return {
383
+ onSchemaChange({ schema }) {
384
+ wrapSchemaResolvers(schema);
385
+ },
386
+ };
387
+ };
388
+
389
+ // Default export with all auth utilities
390
+ const auth = {
391
+ // Main factories
392
+ createAuthPlugin,
393
+ createAuthMiddleware,
394
+ createFieldMiddleware,
395
+
396
+ // Utilities
397
+ resolvePath,
398
+
399
+ // Rule helpers
400
+ requireAuth,
401
+ requireRole,
402
+ requirePermission,
403
+ composeRules,
404
+ anyRule,
405
+ isOwner,
406
+ createRule,
407
+ allow,
408
+ deny,
409
+
410
+ // Expression utilities
411
+ evaluateExpression,
412
+ isPolicyExpression,
413
+ createRuleFromExpression,
414
+
415
+ // Errors
416
+ UnauthenticatedError,
417
+ ForbiddenError,
418
+ createAuthError,
419
+ };
420
+
421
+ export default auth;
@@ -0,0 +1,25 @@
1
+ const objectIdTypes = new Map();
2
+ const canonicalObjectId = /^[0-9a-f]{24}$/;
3
+
4
+ /** Internal adapter hook; database facades register only their authentic ObjectId constructor. */
5
+ export const registerObjectIdType = (ObjectId, toHexString) => {
6
+ if (typeof ObjectId !== 'function' || typeof toHexString !== 'function') {
7
+ throw new TypeError('ObjectId constructor and toHexString method are required');
8
+ }
9
+ if (!objectIdTypes.has(ObjectId)) objectIdTypes.set(ObjectId, toHexString);
10
+ };
11
+
12
+ /** Normalize only instances backed by a registered ObjectId implementation. */
13
+ export const normalizeObjectId = (value) => {
14
+ for (const [ObjectId, toHexString] of objectIdTypes) {
15
+ if (!(value instanceof ObjectId)) continue;
16
+ try {
17
+ const normalized = toHexString.call(value);
18
+ return typeof normalized === 'string' && canonicalObjectId.test(normalized)
19
+ ? normalized : undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ return undefined;
25
+ };