@simtlix/simfinity-js 2.4.6 → 2.5.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.
- package/.claude/worktrees/agitated-kepler/.claude/settings.local.json +23 -0
- package/.claude/worktrees/agitated-kepler/AGGREGATION_CHANGES_SUMMARY.md +235 -0
- package/.claude/worktrees/agitated-kepler/AGGREGATION_EXAMPLE.md +567 -0
- package/.claude/worktrees/agitated-kepler/LICENSE +201 -0
- package/.claude/worktrees/agitated-kepler/README.md +3941 -0
- package/.claude/worktrees/agitated-kepler/eslint.config.mjs +71 -0
- package/.claude/worktrees/agitated-kepler/package-lock.json +4740 -0
- package/.claude/worktrees/agitated-kepler/package.json +41 -0
- package/.claude/worktrees/agitated-kepler/src/auth/errors.js +44 -0
- package/.claude/worktrees/agitated-kepler/src/auth/expressions.js +273 -0
- package/.claude/worktrees/agitated-kepler/src/auth/index.js +391 -0
- package/.claude/worktrees/agitated-kepler/src/auth/rules.js +274 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLOperator.js +39 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLSort.js +28 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLValue.js +39 -0
- package/.claude/worktrees/agitated-kepler/src/errors/internal-server.error.js +11 -0
- package/.claude/worktrees/agitated-kepler/src/errors/simfinity.error.js +15 -0
- package/.claude/worktrees/agitated-kepler/src/index.js +2412 -0
- package/.claude/worktrees/agitated-kepler/src/plugins.js +53 -0
- package/.claude/worktrees/agitated-kepler/src/scalars.js +188 -0
- package/.claude/worktrees/agitated-kepler/src/validators.js +250 -0
- package/.claude/worktrees/agitated-kepler/yarn.lock +1154 -0
- package/.cursor/rules/simfinity-core-functions.mdc +3 -1
- package/README.md +202 -0
- package/package.json +1 -1
- package/src/index.js +235 -21
|
@@ -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
|
+
* 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 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
|
+
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { UnauthenticatedError, ForbiddenError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a value from an object using a dotted path string or function.
|
|
5
|
+
* @param {Object} obj - The object to resolve from
|
|
6
|
+
* @param {string|Function} pathOrFn - Dotted path (e.g., 'user.profile.id') or function to extract value
|
|
7
|
+
* @returns {*} The resolved value or undefined if not found
|
|
8
|
+
* @example
|
|
9
|
+
* resolvePath({ user: { id: '123' } }, 'user.id') // returns '123'
|
|
10
|
+
* resolvePath({ user: { id: '123' } }, (obj) => obj.user.id) // returns '123'
|
|
11
|
+
*/
|
|
12
|
+
export const resolvePath = (obj, pathOrFn) => {
|
|
13
|
+
if (typeof pathOrFn === 'function') {
|
|
14
|
+
return pathOrFn(obj);
|
|
15
|
+
}
|
|
16
|
+
if (typeof pathOrFn === 'string') {
|
|
17
|
+
const parts = pathOrFn.split('.');
|
|
18
|
+
let value = obj;
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (value == null) return undefined;
|
|
21
|
+
value = value[part];
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rule that requires the user to be authenticated
|
|
30
|
+
* Checks for user existence at the specified path in context
|
|
31
|
+
* @param {string|Function} [userPath='user'] - Path to user in context (e.g., 'user', 'auth.user', 'session.user')
|
|
32
|
+
* @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
|
|
33
|
+
* @example
|
|
34
|
+
* requireAuth() // checks ctx.user
|
|
35
|
+
* requireAuth('auth.user') // checks ctx.auth.user
|
|
36
|
+
* requireAuth('session.currentUser') // checks ctx.session.currentUser
|
|
37
|
+
*/
|
|
38
|
+
export const requireAuth = (userPath = 'user') => {
|
|
39
|
+
return (_parent, _args, ctx) => {
|
|
40
|
+
if (!ctx) {
|
|
41
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
42
|
+
}
|
|
43
|
+
const user = resolvePath(ctx, userPath);
|
|
44
|
+
if (!user) {
|
|
45
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rule that requires the user to have a specific role
|
|
53
|
+
* @param {string|string[]} role - Required role or array of roles (any match)
|
|
54
|
+
* @param {Object} [options] - Configuration options
|
|
55
|
+
* @param {string|Function} [options.userPath='user'] - Path to user in context
|
|
56
|
+
* @param {string|Function} [options.rolePath='role'] - Path to role field in user object
|
|
57
|
+
* @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
|
|
58
|
+
* @example
|
|
59
|
+
* requireRole('ADMIN')
|
|
60
|
+
* requireRole(['ADMIN', 'EDITOR'])
|
|
61
|
+
* requireRole('ADMIN', { userPath: 'auth.user', rolePath: 'roles.primary' })
|
|
62
|
+
*/
|
|
63
|
+
export const requireRole = (role, options = {}) => {
|
|
64
|
+
const roles = Array.isArray(role) ? role : [role];
|
|
65
|
+
const { userPath = 'user', rolePath = 'role' } = options;
|
|
66
|
+
|
|
67
|
+
return (_parent, _args, ctx) => {
|
|
68
|
+
if (!ctx) {
|
|
69
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
70
|
+
}
|
|
71
|
+
const user = resolvePath(ctx, userPath);
|
|
72
|
+
if (!user) {
|
|
73
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const userRole = resolvePath(user, rolePath);
|
|
77
|
+
|
|
78
|
+
if (!roles.includes(userRole)) {
|
|
79
|
+
throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rule that requires the user to have a specific permission
|
|
88
|
+
* @param {string|string[]} permission - Required permission(s) (all must match if array)
|
|
89
|
+
* @param {Object} [options] - Configuration options
|
|
90
|
+
* @param {string|Function} [options.userPath='user'] - Path to user in context
|
|
91
|
+
* @param {string|Function} [options.permissionsPath='permissions'] - Path to permissions array in user object
|
|
92
|
+
* @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
|
|
93
|
+
* @example
|
|
94
|
+
* requirePermission('posts:read')
|
|
95
|
+
* requirePermission(['posts:read', 'posts:write'])
|
|
96
|
+
* requirePermission('posts:read', { userPath: 'auth.user', permissionsPath: 'grants' })
|
|
97
|
+
*/
|
|
98
|
+
export const requirePermission = (permission, options = {}) => {
|
|
99
|
+
const requiredPermissions = Array.isArray(permission) ? permission : [permission];
|
|
100
|
+
const { userPath = 'user', permissionsPath = 'permissions' } = options;
|
|
101
|
+
|
|
102
|
+
return (_parent, _args, ctx) => {
|
|
103
|
+
if (!ctx) {
|
|
104
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
105
|
+
}
|
|
106
|
+
const user = resolvePath(ctx, userPath);
|
|
107
|
+
if (!user) {
|
|
108
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const userPermissions = resolvePath(user, permissionsPath) || [];
|
|
112
|
+
|
|
113
|
+
// Check if user has wildcard permission
|
|
114
|
+
if (userPermissions.includes('*')) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// All required permissions must be present
|
|
119
|
+
for (const perm of requiredPermissions) {
|
|
120
|
+
if (!userPermissions.includes(perm)) {
|
|
121
|
+
throw new ForbiddenError(`Missing permission: ${perm}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Composes multiple rules - all must pass (logical AND)
|
|
131
|
+
* @param {...Function} rules - Rule functions to compose
|
|
132
|
+
* @returns {Function} Composed rule function
|
|
133
|
+
*/
|
|
134
|
+
export const composeRules = (...rules) => {
|
|
135
|
+
return async (parent, args, ctx, info) => {
|
|
136
|
+
for (const rule of rules) {
|
|
137
|
+
const result = await rule(parent, args, ctx, info);
|
|
138
|
+
// If rule returns false, deny access
|
|
139
|
+
if (result === false) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
// If rule throws, it will propagate
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Creates a rule that allows access if ANY of the provided rules pass (logical OR)
|
|
150
|
+
* @param {...Function} rules - Rule functions to check
|
|
151
|
+
* @returns {Function} Combined rule function
|
|
152
|
+
*/
|
|
153
|
+
export const anyRule = (...rules) => {
|
|
154
|
+
return async (parent, args, ctx, info) => {
|
|
155
|
+
let lastError = null;
|
|
156
|
+
|
|
157
|
+
for (const rule of rules) {
|
|
158
|
+
try {
|
|
159
|
+
const result = await rule(parent, args, ctx, info);
|
|
160
|
+
if (result !== false) {
|
|
161
|
+
return true; // At least one rule passed
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
lastError = error;
|
|
165
|
+
// Continue to next rule
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// No rule passed - throw the last error or return false
|
|
170
|
+
if (lastError) {
|
|
171
|
+
throw lastError;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Creates a rule that checks if the authenticated user owns the resource
|
|
179
|
+
* @param {string|Function} [ownerField='userId'] - Path to owner ID in parent, or function to extract it
|
|
180
|
+
* @param {string|Function} [userIdField='id'] - Path to user ID in user object, or function to extract it
|
|
181
|
+
* @param {Object} [options] - Configuration options
|
|
182
|
+
* @param {string|Function} [options.userPath='user'] - Path to user in context
|
|
183
|
+
* @returns {Function} Rule function
|
|
184
|
+
* @example
|
|
185
|
+
* isOwner() // compares parent.userId with ctx.user.id
|
|
186
|
+
* isOwner('authorId') // compares parent.authorId with ctx.user.id
|
|
187
|
+
* isOwner('author.id', 'profile.id') // compares parent.author.id with ctx.user.profile.id
|
|
188
|
+
* isOwner('authorId', 'id', { userPath: 'auth.user' }) // uses ctx.auth.user instead of ctx.user
|
|
189
|
+
*/
|
|
190
|
+
export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
|
|
191
|
+
const { userPath = 'user' } = options;
|
|
192
|
+
|
|
193
|
+
return (parent, _args, ctx) => {
|
|
194
|
+
if (!ctx) {
|
|
195
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
196
|
+
}
|
|
197
|
+
const user = resolvePath(ctx, userPath);
|
|
198
|
+
if (!user) {
|
|
199
|
+
throw new UnauthenticatedError('You must be logged in to access this resource');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Get ownerId from parent (using path or function)
|
|
203
|
+
const ownerId = resolvePath(parent, ownerField);
|
|
204
|
+
|
|
205
|
+
// Get userId from user object (using path or function)
|
|
206
|
+
const userId = resolvePath(user, userIdField);
|
|
207
|
+
|
|
208
|
+
if (ownerId === undefined || userId === undefined) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return String(ownerId) === String(userId);
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Creates a custom rule from a predicate function
|
|
218
|
+
* @param {Function} predicate - Function (parent, args, ctx, info) => boolean | Promise<boolean>
|
|
219
|
+
* @param {string} errorMessage - Error message if rule fails
|
|
220
|
+
* @param {string} errorCode - Error code (FORBIDDEN or UNAUTHENTICATED)
|
|
221
|
+
* @returns {Function} Rule function
|
|
222
|
+
*/
|
|
223
|
+
export const createRule = (predicate, errorMessage = 'Access denied', errorCode = 'FORBIDDEN') => {
|
|
224
|
+
return async (parent, args, ctx, info) => {
|
|
225
|
+
const result = await predicate(parent, args, ctx, info);
|
|
226
|
+
|
|
227
|
+
if (result === false) {
|
|
228
|
+
if (errorCode === 'UNAUTHENTICATED') {
|
|
229
|
+
throw new UnauthenticatedError(errorMessage);
|
|
230
|
+
}
|
|
231
|
+
throw new ForbiddenError(errorMessage);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return true;
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Rule that always allows access (useful for public fields)
|
|
240
|
+
* @returns {Function} Rule function that always returns true
|
|
241
|
+
*/
|
|
242
|
+
export const allow = () => {
|
|
243
|
+
return () => true;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Rule that always denies access
|
|
248
|
+
* @param {string} message - Optional denial message
|
|
249
|
+
* @returns {Function} Rule function that always throws ForbiddenError
|
|
250
|
+
*/
|
|
251
|
+
export const deny = (message = 'Access denied') => {
|
|
252
|
+
return () => {
|
|
253
|
+
throw new ForbiddenError(message);
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Export all rules as an object for convenience
|
|
258
|
+
const rules = {
|
|
259
|
+
// Utility
|
|
260
|
+
resolvePath,
|
|
261
|
+
// Rule helpers
|
|
262
|
+
requireAuth,
|
|
263
|
+
requireRole,
|
|
264
|
+
requirePermission,
|
|
265
|
+
composeRules,
|
|
266
|
+
anyRule,
|
|
267
|
+
isOwner,
|
|
268
|
+
createRule,
|
|
269
|
+
allow,
|
|
270
|
+
deny,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export default rules;
|
|
274
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { GraphQLEnumType } from 'graphql';
|
|
2
|
+
|
|
3
|
+
const QLOperator = new GraphQLEnumType({
|
|
4
|
+
name: 'QLOperator',
|
|
5
|
+
values: {
|
|
6
|
+
EQ: {
|
|
7
|
+
value: 'EQ',
|
|
8
|
+
},
|
|
9
|
+
LT: {
|
|
10
|
+
value: 'LT',
|
|
11
|
+
},
|
|
12
|
+
GT: {
|
|
13
|
+
value: 'GT',
|
|
14
|
+
},
|
|
15
|
+
LTE: {
|
|
16
|
+
value: 'LTE',
|
|
17
|
+
},
|
|
18
|
+
GTE: {
|
|
19
|
+
value: 'GTE',
|
|
20
|
+
},
|
|
21
|
+
BTW: {
|
|
22
|
+
value: 'BTW',
|
|
23
|
+
},
|
|
24
|
+
NE: {
|
|
25
|
+
value: 'NE',
|
|
26
|
+
},
|
|
27
|
+
IN: {
|
|
28
|
+
value: 'IN',
|
|
29
|
+
},
|
|
30
|
+
NIN: {
|
|
31
|
+
value: 'NIN',
|
|
32
|
+
},
|
|
33
|
+
LIKE: {
|
|
34
|
+
value: 'LIKE',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default QLOperator;
|