@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.
- package/README.md +394 -0
- package/package.json +2 -1
- package/src/auth/errors.js +44 -0
- package/src/auth/expressions.js +273 -0
- package/src/auth/index.js +391 -0
- package/src/auth/rules.js +274 -0
- package/src/index.js +1 -0
- package/src/plugins.js +10 -7
|
@@ -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
|
+
|