@simtlix/simfinity-js 2.3.4 → 2.4.1

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,273 @@
1
+ /**
2
+ * JSON AST Policy Expression Evaluator
3
+ *
4
+ * Safely evaluates declarative policy expressions without using eval() or Function().
5
+ * Supports logical operators (allOf, anyOf, not), comparison operators (eq, in),
6
+ * boolean literals, and references to parent/args/ctx.
7
+ *
8
+ * Unknown operators or invalid references fail closed (deny).
9
+ */
10
+
11
+ /**
12
+ * Resolves a reference path like "parent.authorId" or "ctx.user.id"
13
+ * @param {string} refPath - The reference path (e.g., "ctx.user.id")
14
+ * @param {Object} context - The evaluation context { parent, args, ctx }
15
+ * @returns {*} The resolved value or undefined if not found
16
+ */
17
+ const resolveRef = (refPath, context) => {
18
+ if (typeof refPath !== 'string') {
19
+ return undefined;
20
+ }
21
+
22
+ const parts = refPath.split('.');
23
+ const root = parts[0];
24
+
25
+ // Only allow parent, args, ctx as root references
26
+ if (!['parent', 'args', 'ctx'].includes(root)) {
27
+ return undefined;
28
+ }
29
+
30
+ let value = context[root];
31
+
32
+ for (let i = 1; i < parts.length; i++) {
33
+ if (value === null || value === undefined) {
34
+ return undefined;
35
+ }
36
+ value = value[parts[i]];
37
+ }
38
+
39
+ return value;
40
+ };
41
+
42
+ /**
43
+ * Resolves a value which may be a literal or a reference
44
+ * @param {*} value - The value to resolve (could be { ref: "..." } or a literal)
45
+ * @param {Object} context - The evaluation context { parent, args, ctx }
46
+ * @returns {*} The resolved value
47
+ */
48
+ const resolveValue = (value, context) => {
49
+ // Check if it's a reference object
50
+ if (value !== null && typeof value === 'object' && 'ref' in value) {
51
+ return resolveRef(value.ref, context);
52
+ }
53
+ // Return literal value
54
+ return value;
55
+ };
56
+
57
+ /**
58
+ * Evaluates an 'eq' expression: { eq: [left, right] }
59
+ * @param {Array} operands - Array of two operands to compare
60
+ * @param {Object} context - The evaluation context
61
+ * @returns {boolean}
62
+ */
63
+ const evaluateEq = (operands, context) => {
64
+ if (!Array.isArray(operands) || operands.length !== 2) {
65
+ return false; // Fail closed
66
+ }
67
+ const left = resolveValue(operands[0], context);
68
+ const right = resolveValue(operands[1], context);
69
+ return left === right;
70
+ };
71
+
72
+ /**
73
+ * Evaluates an 'in' expression: { in: [value, array] }
74
+ * @param {Array} operands - [value, array] where value should be in array
75
+ * @param {Object} context - The evaluation context
76
+ * @returns {boolean}
77
+ */
78
+ const evaluateIn = (operands, context) => {
79
+ if (!Array.isArray(operands) || operands.length !== 2) {
80
+ return false; // Fail closed
81
+ }
82
+ const value = resolveValue(operands[0], context);
83
+ const array = resolveValue(operands[1], context);
84
+
85
+ if (!Array.isArray(array)) {
86
+ return false; // Fail closed
87
+ }
88
+
89
+ return array.includes(value);
90
+ };
91
+
92
+ /**
93
+ * Evaluates an 'allOf' expression: { allOf: [...expressions] }
94
+ * All expressions must evaluate to true (logical AND)
95
+ * @param {Array} expressions - Array of expressions to evaluate
96
+ * @param {Object} context - The evaluation context
97
+ * @returns {boolean}
98
+ */
99
+ const evaluateAllOf = (expressions, context) => {
100
+ if (!Array.isArray(expressions)) {
101
+ return false; // Fail closed
102
+ }
103
+
104
+ for (const expr of expressions) {
105
+ if (!evaluateExpression(expr, context)) {
106
+ return false;
107
+ }
108
+ }
109
+ return true;
110
+ };
111
+
112
+ /**
113
+ * Evaluates an 'anyOf' expression: { anyOf: [...expressions] }
114
+ * At least one expression must evaluate to true (logical OR)
115
+ * @param {Array} expressions - Array of expressions to evaluate
116
+ * @param {Object} context - The evaluation context
117
+ * @returns {boolean}
118
+ */
119
+ const evaluateAnyOf = (expressions, context) => {
120
+ if (!Array.isArray(expressions)) {
121
+ return false; // Fail closed
122
+ }
123
+
124
+ for (const expr of expressions) {
125
+ if (evaluateExpression(expr, context)) {
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ };
131
+
132
+ /**
133
+ * Evaluates a 'not' expression: { not: expression }
134
+ * Negates the result of the inner expression
135
+ * @param {*} expression - Expression to negate
136
+ * @param {Object} context - The evaluation context
137
+ * @returns {boolean}
138
+ */
139
+ const evaluateNot = (expression, context) => {
140
+ return !evaluateExpression(expression, context);
141
+ };
142
+
143
+ /**
144
+ * Main expression evaluator
145
+ * @param {*} expression - The expression to evaluate
146
+ * @param {Object} context - The evaluation context { parent, args, ctx }
147
+ * @returns {boolean} The result of the expression evaluation
148
+ */
149
+ export const evaluateExpression = (expression, context) => {
150
+ // Handle boolean literals
151
+ if (typeof expression === 'boolean') {
152
+ return expression;
153
+ }
154
+
155
+ // Handle null/undefined - fail closed
156
+ if (expression === null || expression === undefined) {
157
+ return false;
158
+ }
159
+
160
+ // Expression must be an object with exactly one operator key
161
+ if (typeof expression !== 'object') {
162
+ return false; // Fail closed for non-object expressions
163
+ }
164
+
165
+ const keys = Object.keys(expression);
166
+
167
+ // Empty object fails closed
168
+ if (keys.length === 0) {
169
+ return false;
170
+ }
171
+
172
+ // Handle single operator expressions
173
+ if (keys.length === 1) {
174
+ const operator = keys[0];
175
+ const operand = expression[operator];
176
+
177
+ switch (operator) {
178
+ case 'eq':
179
+ return evaluateEq(operand, context);
180
+ case 'in':
181
+ return evaluateIn(operand, context);
182
+ case 'allOf':
183
+ return evaluateAllOf(operand, context);
184
+ case 'anyOf':
185
+ return evaluateAnyOf(operand, context);
186
+ case 'not':
187
+ return evaluateNot(operand, context);
188
+ default:
189
+ // Unknown operator - fail closed
190
+ return false;
191
+ }
192
+ }
193
+
194
+ // Multiple keys - treat as implicit allOf
195
+ // This allows { eq: [...], in: [...] } to mean both must pass
196
+ for (const operator of keys) {
197
+ const operand = expression[operator];
198
+ let result;
199
+
200
+ switch (operator) {
201
+ case 'eq':
202
+ result = evaluateEq(operand, context);
203
+ break;
204
+ case 'in':
205
+ result = evaluateIn(operand, context);
206
+ break;
207
+ case 'allOf':
208
+ result = evaluateAllOf(operand, context);
209
+ break;
210
+ case 'anyOf':
211
+ result = evaluateAnyOf(operand, context);
212
+ break;
213
+ case 'not':
214
+ result = evaluateNot(operand, context);
215
+ break;
216
+ default:
217
+ // Unknown operator - fail closed
218
+ return false;
219
+ }
220
+
221
+ if (!result) {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ return true;
227
+ };
228
+
229
+ /**
230
+ * Checks if a value is a policy expression (object with operator keys)
231
+ * @param {*} value - The value to check
232
+ * @returns {boolean} True if the value appears to be a policy expression
233
+ */
234
+ export const isPolicyExpression = (value) => {
235
+ if (value === null || typeof value !== 'object') {
236
+ return false;
237
+ }
238
+
239
+ // Check if it's a function (not an expression)
240
+ if (typeof value === 'function') {
241
+ return false;
242
+ }
243
+
244
+ // Check if it has any known operator keys
245
+ const operatorKeys = ['eq', 'in', 'allOf', 'anyOf', 'not'];
246
+ const keys = Object.keys(value);
247
+
248
+ return keys.some(key => operatorKeys.includes(key));
249
+ };
250
+
251
+ /**
252
+ * Creates a rule function from a policy expression
253
+ * @param {Object} expression - The policy expression
254
+ * @returns {Function} A rule function (parent, args, ctx, info) => boolean
255
+ */
256
+ export const createRuleFromExpression = (expression) => {
257
+ return (parent, args, ctx) => {
258
+ const context = { parent, args, ctx };
259
+ return evaluateExpression(expression, context);
260
+ };
261
+ };
262
+
263
+ // Export all expression utilities as an object for convenience
264
+ const expressions = {
265
+ evaluateExpression,
266
+ isPolicyExpression,
267
+ createRuleFromExpression,
268
+ resolveRef,
269
+ resolveValue,
270
+ };
271
+
272
+ export default expressions;
273
+
@@ -0,0 +1,391 @@
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
+ * users: requireAuth(),
20
+ * adminDashboard: requireRole('ADMIN')
21
+ * },
22
+ * Mutation: {
23
+ * publishPost: requireRole('EDITOR')
24
+ * },
25
+ * User: {
26
+ * '*': requireAuth(),
27
+ * email: requireRole('ADMIN')
28
+ * }
29
+ * };
30
+ *
31
+ * const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
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 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 {Object|RuleFunction|Array<RuleFunction>} 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
+ return rule.flatMap(r => normalizeRule(r));
114
+ }
115
+
116
+ if (isPolicyExpression(rule)) {
117
+ return [createRuleFromExpression(rule)];
118
+ }
119
+
120
+ // Unknown rule type - return empty (will use default policy)
121
+ return [];
122
+ };
123
+
124
+ /**
125
+ * Gets the rule for a specific field, with wildcard fallback
126
+ * @param {PermissionSchema} permissions - The permission schema
127
+ * @param {string} typeName - The GraphQL type name
128
+ * @param {string} fieldName - The field name
129
+ * @returns {Function[]|null} Array of rule functions or null if no rule found
130
+ */
131
+ const getFieldRules = (permissions, typeName, fieldName) => {
132
+ const typePerms = permissions[typeName];
133
+
134
+ if (!typePerms) {
135
+ return null;
136
+ }
137
+
138
+ // Check for exact field rule first
139
+ if (fieldName in typePerms) {
140
+ return normalizeRule(typePerms[fieldName]);
141
+ }
142
+
143
+ // Fallback to wildcard
144
+ if ('*' in typePerms) {
145
+ return normalizeRule(typePerms['*']);
146
+ }
147
+
148
+ return null;
149
+ };
150
+
151
+ /**
152
+ * Executes a single rule
153
+ * @param {Function} rule - The rule function to execute
154
+ * @param {*} parent - Parent resolver result
155
+ * @param {Object} args - GraphQL arguments
156
+ * @param {Object} ctx - GraphQL context
157
+ * @param {Object} info - GraphQL resolve info
158
+ * @returns {Promise<boolean>} True if allowed, false if denied
159
+ */
160
+ const executeRule = async (rule, parent, args, ctx, info) => {
161
+ const result = await rule(parent, args, ctx, info);
162
+
163
+ // void/undefined/true means allow
164
+ if (result === undefined || result === true) {
165
+ return true;
166
+ }
167
+
168
+ // false means deny
169
+ return false;
170
+ };
171
+
172
+ /**
173
+ * Creates a graphql-middleware compatible authorization middleware.
174
+ *
175
+ * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
176
+ * can cause duplicate-type errors when the schema contains custom introspection extensions.
177
+ *
178
+ * @param {PermissionSchema} permissions - The permission schema object
179
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
180
+ * @returns {Function} A graphql-middleware compatible middleware function
181
+ */
182
+ export const createAuthMiddleware = (permissions, options = {}) => {
183
+ const {
184
+ defaultPolicy = 'DENY',
185
+ debug = false,
186
+ } = options;
187
+
188
+ const log = debug ? console.log.bind(console, '[auth]') : () => {};
189
+
190
+ /**
191
+ * The middleware generator function
192
+ * Returns a middleware object keyed by type name, each containing field resolvers
193
+ */
194
+ return async (resolve, parent, args, ctx, info) => {
195
+ const typeName = info.parentType.name;
196
+ const fieldName = info.fieldName;
197
+
198
+ log(`Checking ${typeName}.${fieldName}`);
199
+
200
+ // Get rules for this field
201
+ const rules = getFieldRules(permissions, typeName, fieldName);
202
+
203
+ // If no rules found, apply default policy
204
+ if (rules === null || rules.length === 0) {
205
+ log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
206
+
207
+ if (defaultPolicy === 'DENY') {
208
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
209
+ }
210
+
211
+ // ALLOW - proceed to resolver
212
+ return resolve(parent, args, ctx, info);
213
+ }
214
+
215
+ // Execute all rules (AND logic - all must pass)
216
+ for (const rule of rules) {
217
+ log(`Executing rule for ${typeName}.${fieldName}`);
218
+
219
+ const allowed = await executeRule(rule, parent, args, ctx, info);
220
+
221
+ if (!allowed) {
222
+ log(`Rule denied access to ${typeName}.${fieldName}`);
223
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
224
+ }
225
+ }
226
+
227
+ log(`Access granted to ${typeName}.${fieldName}`);
228
+
229
+ // All rules passed - proceed to resolver
230
+ return resolve(parent, args, ctx, info);
231
+ };
232
+ };
233
+
234
+ /**
235
+ * Creates a field-level middleware object from a permission schema.
236
+ * This can be used with graphql-middleware's applyMiddleware.
237
+ *
238
+ * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
239
+ * can cause duplicate-type errors when the schema contains custom introspection extensions.
240
+ *
241
+ * @param {PermissionSchema} permissions - The permission schema
242
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
243
+ * @returns {Object} Field middleware object compatible with graphql-middleware
244
+ */
245
+ export const createFieldMiddleware = (permissions, options = {}) => {
246
+ const middleware = createAuthMiddleware(permissions, options);
247
+ const fieldMiddleware = {};
248
+
249
+ for (const typeName of Object.keys(permissions)) {
250
+ fieldMiddleware[typeName] = {};
251
+
252
+ const typePerms = permissions[typeName];
253
+ for (const fieldName of Object.keys(typePerms)) {
254
+ if (fieldName === '*') {
255
+ // Wildcard rules are handled by the middleware internally
256
+ continue;
257
+ }
258
+ fieldMiddleware[typeName][fieldName] = middleware;
259
+ }
260
+ }
261
+
262
+ return fieldMiddleware;
263
+ };
264
+
265
+ /**
266
+ * Creates an Envelop-compatible authorization plugin that wraps schema resolvers in-place.
267
+ *
268
+ * Unlike {@link createAuthMiddleware} (which requires graphql-middleware's `applyMiddleware`
269
+ * and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
270
+ * avoiding schema reconstruction and the duplicate-type errors it can cause.
271
+ *
272
+ * @param {PermissionSchema} permissions - The permission schema object
273
+ * @param {AuthMiddlewareOptions} [options={}] - Plugin options
274
+ * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
275
+ *
276
+ * @example
277
+ * import { auth } from '@simtlix/simfinity-js';
278
+ * import { createYoga } from 'graphql-yoga';
279
+ *
280
+ * const permissions = {
281
+ * Query: {
282
+ * users: requireAuth(),
283
+ * adminDashboard: requireRole('ADMIN')
284
+ * },
285
+ * User: {
286
+ * '*': requireAuth(),
287
+ * email: requireRole('ADMIN')
288
+ * }
289
+ * };
290
+ *
291
+ * const authPlugin = auth.createAuthPlugin(permissions, { defaultPolicy: 'DENY' });
292
+ * const yoga = createYoga({ schema, plugins: [authPlugin] });
293
+ */
294
+ export const createAuthPlugin = (permissions, options = {}) => {
295
+ const {
296
+ defaultPolicy = 'DENY',
297
+ debug = false,
298
+ } = options;
299
+
300
+ const log = debug ? console.log.bind(console, '[auth]') : () => {};
301
+ const processedSchemas = new WeakSet();
302
+
303
+ const wrapSchemaResolvers = (schema) => {
304
+ if (processedSchemas.has(schema)) return;
305
+
306
+ const typeMap = schema.getTypeMap();
307
+
308
+ for (const [typeName, type] of Object.entries(typeMap)) {
309
+ if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__')) continue;
310
+
311
+ const fields = type.getFields();
312
+
313
+ for (const [fieldName, field] of Object.entries(fields)) {
314
+ const rules = getFieldRules(permissions, typeName, fieldName);
315
+ const originalResolve = field.resolve || defaultFieldResolver;
316
+
317
+ field.resolve = async (parent, args, ctx, info) => {
318
+ log(`Checking ${typeName}.${fieldName}`);
319
+
320
+ if (rules === null || rules.length === 0) {
321
+ log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
322
+
323
+ if (defaultPolicy === 'DENY') {
324
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
325
+ }
326
+
327
+ return originalResolve(parent, args, ctx, info);
328
+ }
329
+
330
+ for (const rule of rules) {
331
+ log(`Executing rule for ${typeName}.${fieldName}`);
332
+
333
+ const allowed = await executeRule(rule, parent, args, ctx, info);
334
+
335
+ if (!allowed) {
336
+ log(`Rule denied access to ${typeName}.${fieldName}`);
337
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
338
+ }
339
+ }
340
+
341
+ log(`Access granted to ${typeName}.${fieldName}`);
342
+
343
+ return originalResolve(parent, args, ctx, info);
344
+ };
345
+ }
346
+ }
347
+
348
+ processedSchemas.add(schema);
349
+ };
350
+
351
+ return {
352
+ onSchemaChange({ schema }) {
353
+ wrapSchemaResolvers(schema);
354
+ },
355
+ };
356
+ };
357
+
358
+ // Default export with all auth utilities
359
+ const auth = {
360
+ // Main factories
361
+ createAuthPlugin,
362
+ createAuthMiddleware,
363
+ createFieldMiddleware,
364
+
365
+ // Utilities
366
+ resolvePath,
367
+
368
+ // Rule helpers
369
+ requireAuth,
370
+ requireRole,
371
+ requirePermission,
372
+ composeRules,
373
+ anyRule,
374
+ isOwner,
375
+ createRule,
376
+ allow,
377
+ deny,
378
+
379
+ // Expression utilities
380
+ evaluateExpression,
381
+ isPolicyExpression,
382
+ createRuleFromExpression,
383
+
384
+ // Errors
385
+ UnauthenticatedError,
386
+ ForbiddenError,
387
+ createAuthError,
388
+ };
389
+
390
+ export default auth;
391
+