@simtlix/simfinity-js 2.3.2 → 2.4.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/README.md CHANGED
@@ -20,6 +20,12 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
20
20
  - [Adding Middlewares](#adding-middlewares)
21
21
  - [Middleware Parameters](#middleware-parameters)
22
22
  - [Common Use Cases](#common-use-cases)
23
+ - [Authorization Middleware](#-authorization-middleware)
24
+ - [Quick Start](#quick-start-1)
25
+ - [Permission Schema](#permission-schema)
26
+ - [Rule Helpers](#rule-helpers)
27
+ - [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
28
+ - [Integration with graphql-middleware](#integration-with-graphql-middleware)
23
29
  - [Relationships](#-relationships)
24
30
  - [Defining Relationships](#defining-relationships)
25
31
  - [Auto-Generated Resolve Methods](#auto-generated-resolve-methods)
@@ -69,6 +75,7 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
69
75
  - **Lifecycle Hooks**: Controller methods for granular control over operations
70
76
  - **Custom Validation**: Field-level and type-level custom validations
71
77
  - **Relationship Management**: Support for embedded and referenced relationships
78
+ - **Authorization Middleware**: Production-grade GraphQL authorization with RBAC/ABAC, function-based rules, and declarative policy expressions
72
79
 
73
80
  ## 📦 Installation
74
81
 
@@ -622,6 +629,378 @@ simfinity.use((params, next) => {
622
629
  5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
623
630
  6. **Use context wisely**: Store request-specific data in the GraphQL context object
624
631
 
632
+ ## 🔐 Authorization Middleware
633
+
634
+ Simfinity.js provides a production-grade centralized GraphQL authorization middleware supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies.
635
+
636
+ ### Quick Start
637
+
638
+ ```javascript
639
+ const { auth } = require('@simtlix/simfinity-js');
640
+ const { applyMiddleware } = require('graphql-middleware');
641
+
642
+ const { createAuthMiddleware, requireAuth, requireRole } = auth;
643
+
644
+ // Define your permission schema
645
+ const permissions = {
646
+ Query: {
647
+ users: requireAuth(),
648
+ adminDashboard: requireRole('ADMIN'),
649
+ },
650
+ Mutation: {
651
+ publishPost: requireRole('EDITOR'),
652
+ },
653
+ User: {
654
+ '*': requireAuth(), // Wildcard: all fields require auth
655
+ email: requireRole('ADMIN'), // Override: email requires ADMIN role
656
+ },
657
+ Post: {
658
+ '*': requireAuth(),
659
+ content: async (post, _args, ctx) => {
660
+ // Custom logic: allow if published OR if author
661
+ if (post.published) return true;
662
+ if (post.authorId === ctx.user?.id) return true;
663
+ return false;
664
+ },
665
+ },
666
+ };
667
+
668
+ // Create and apply the middleware
669
+ const authMiddleware = createAuthMiddleware(permissions, { defaultPolicy: 'DENY' });
670
+ const schemaWithAuth = applyMiddleware(schema, authMiddleware);
671
+ ```
672
+
673
+ ### Permission Schema
674
+
675
+ The permission schema defines authorization rules per type and field:
676
+
677
+ ```javascript
678
+ const permissions = {
679
+ // Operation types (Query, Mutation, Subscription)
680
+ Query: {
681
+ fieldName: ruleOrRules,
682
+ },
683
+
684
+ // Object types
685
+ TypeName: {
686
+ '*': wildcardRule, // Applies to all fields unless overridden
687
+ fieldName: specificRule, // Overrides wildcard for this field
688
+ },
689
+ };
690
+ ```
691
+
692
+ **Resolution Order:**
693
+ 1. Check exact field rule: `permissions[TypeName][fieldName]`
694
+ 2. Fallback to wildcard: `permissions[TypeName]['*']`
695
+ 3. Apply default policy (ALLOW or DENY)
696
+
697
+ **Rule Types:**
698
+ - **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
699
+ - **Array of functions**: All rules must pass (AND logic)
700
+ - **Policy expression**: JSON AST object (see below)
701
+
702
+ **Rule Semantics:**
703
+ - `return true` or `return void` → allow
704
+ - `return false` → deny
705
+ - `throw Error` → deny with error
706
+
707
+ ### Rule Helpers
708
+
709
+ Simfinity.js provides reusable rule builders:
710
+
711
+ ```javascript
712
+ const { auth } = require('@simtlix/simfinity-js');
713
+
714
+ const {
715
+ resolvePath, // Utility to resolve dotted paths in objects
716
+ requireAuth, // Requires ctx.user to exist
717
+ requireRole, // Requires specific role(s)
718
+ requirePermission, // Requires specific permission(s)
719
+ composeRules, // Combine rules (AND logic)
720
+ anyRule, // Combine rules (OR logic)
721
+ isOwner, // Check resource ownership
722
+ allow, // Always allow
723
+ deny, // Always deny
724
+ createRule, // Create custom rule
725
+ } = auth;
726
+ ```
727
+
728
+ #### requireAuth(userPath?)
729
+
730
+ Requires the user to be authenticated. Supports custom user paths in context:
731
+
732
+ ```javascript
733
+ const permissions = {
734
+ Query: {
735
+ // Default: checks ctx.user
736
+ me: requireAuth(),
737
+
738
+ // Custom path: checks ctx.auth.currentUser
739
+ profile: requireAuth('auth.currentUser'),
740
+
741
+ // Deep path: checks ctx.session.data.user
742
+ settings: requireAuth('session.data.user'),
743
+ },
744
+ };
745
+ ```
746
+
747
+ #### requireRole(role, options?)
748
+
749
+ Requires the user to have a specific role. Supports custom paths:
750
+
751
+ ```javascript
752
+ const permissions = {
753
+ Query: {
754
+ // Default: checks ctx.user.role
755
+ adminDashboard: requireRole('ADMIN'),
756
+ modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
757
+
758
+ // Custom paths: checks ctx.auth.user.profile.role
759
+ superAdmin: requireRole('SUPER_ADMIN', {
760
+ userPath: 'auth.user',
761
+ rolePath: 'profile.role',
762
+ }),
763
+ },
764
+ };
765
+ ```
766
+
767
+ #### requirePermission(permission, options?)
768
+
769
+ Requires the user to have specific permission(s). Supports custom paths:
770
+
771
+ ```javascript
772
+ const permissions = {
773
+ Mutation: {
774
+ // Default: checks ctx.user.permissions
775
+ deletePost: requirePermission('posts:delete'),
776
+ manageUsers: requirePermission(['users:read', 'users:write']), // All required
777
+
778
+ // Custom paths: checks ctx.session.user.access.grants
779
+ admin: requirePermission('admin:all', {
780
+ userPath: 'session.user',
781
+ permissionsPath: 'access.grants',
782
+ }),
783
+ },
784
+ };
785
+ ```
786
+
787
+ #### composeRules(...rules)
788
+
789
+ Combines multiple rules with AND logic (all must pass):
790
+
791
+ ```javascript
792
+ const permissions = {
793
+ Mutation: {
794
+ updatePost: composeRules(
795
+ requireAuth(),
796
+ requireRole('EDITOR'),
797
+ async (post, args, ctx) => post.authorId === ctx.user.id,
798
+ ),
799
+ },
800
+ };
801
+ ```
802
+
803
+ #### anyRule(...rules)
804
+
805
+ Combines multiple rules with OR logic (any must pass):
806
+
807
+ ```javascript
808
+ const permissions = {
809
+ Post: {
810
+ content: anyRule(
811
+ requireRole('ADMIN'),
812
+ async (post, args, ctx) => post.authorId === ctx.user.id,
813
+ ),
814
+ },
815
+ };
816
+ ```
817
+
818
+ #### isOwner(ownerField, userIdField)
819
+
820
+ Checks if the authenticated user owns the resource:
821
+
822
+ ```javascript
823
+ const permissions = {
824
+ Post: {
825
+ '*': composeRules(
826
+ requireAuth(),
827
+ isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
828
+ ),
829
+ },
830
+ };
831
+ ```
832
+
833
+ ### Policy Expressions (JSON AST)
834
+
835
+ For declarative rules, use JSON AST policy expressions:
836
+
837
+ ```javascript
838
+ const permissions = {
839
+ Post: {
840
+ content: {
841
+ anyOf: [
842
+ { eq: [{ ref: 'parent.published' }, true] },
843
+ { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
844
+ ],
845
+ },
846
+ },
847
+ };
848
+ ```
849
+
850
+ **Supported Operators:**
851
+
852
+ | Operator | Description | Example |
853
+ |----------|-------------|---------|
854
+ | `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
855
+ | `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
856
+ | `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
857
+ | `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
858
+ | `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
859
+
860
+ **References:**
861
+
862
+ Use `{ ref: 'path' }` to reference values:
863
+ - `parent.*` - Parent resolver result (the object being resolved)
864
+ - `args.*` - GraphQL arguments
865
+ - `ctx.*` - GraphQL context
866
+
867
+ **Security:**
868
+ - Only `parent`, `args`, and `ctx` roots are allowed
869
+ - Unknown operators fail closed (deny)
870
+ - No `eval()` or `Function()` - pure object traversal
871
+
872
+ ### Integration with graphql-middleware
873
+
874
+ The auth middleware integrates with the `graphql-middleware` package:
875
+
876
+ ```javascript
877
+ const express = require('express');
878
+ const { graphqlHTTP } = require('express-graphql');
879
+ const { applyMiddleware } = require('graphql-middleware');
880
+ const simfinity = require('@simtlix/simfinity-js');
881
+
882
+ const { auth } = simfinity;
883
+ const { createAuthMiddleware, requireAuth, requireRole, requirePermission } = auth;
884
+
885
+ // Define your types and connect them
886
+ simfinity.connect(null, UserType, 'user', 'users');
887
+ simfinity.connect(null, PostType, 'post', 'posts');
888
+
889
+ // Create base schema
890
+ const baseSchema = simfinity.createSchema();
891
+
892
+ // Define permissions
893
+ const permissions = {
894
+ Query: {
895
+ users: requireAuth(),
896
+ user: requireAuth(),
897
+ posts: requireAuth(),
898
+ post: requireAuth(),
899
+ },
900
+ Mutation: {
901
+ adduser: requireRole('ADMIN'),
902
+ updateuser: requireRole('ADMIN'),
903
+ deleteuser: requireRole('ADMIN'),
904
+ addpost: requireAuth(),
905
+ updatepost: composeRules(requireAuth(), isOwner('authorId')),
906
+ deletepost: requireRole('ADMIN'),
907
+ },
908
+ User: {
909
+ '*': requireAuth(),
910
+ email: requireRole('ADMIN'),
911
+ password: deny('Password field is not accessible'),
912
+ },
913
+ Post: {
914
+ '*': requireAuth(),
915
+ content: {
916
+ anyOf: [
917
+ { eq: [{ ref: 'parent.published' }, true] },
918
+ { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
919
+ ],
920
+ },
921
+ },
922
+ };
923
+
924
+ // Create auth middleware
925
+ const authMiddleware = createAuthMiddleware(permissions, {
926
+ defaultPolicy: 'DENY', // Deny access when no rule matches
927
+ debug: false, // Enable for debugging
928
+ });
929
+
930
+ // Apply middleware to schema
931
+ const schema = applyMiddleware(baseSchema, authMiddleware);
932
+
933
+ // Setup Express with context
934
+ const app = express();
935
+
936
+ app.use('/graphql', graphqlHTTP((req) => ({
937
+ schema,
938
+ graphiql: true,
939
+ context: {
940
+ user: req.user, // Set by your authentication middleware
941
+ },
942
+ formatError: simfinity.buildErrorFormatter((err) => {
943
+ console.error(err);
944
+ }),
945
+ })));
946
+
947
+ app.listen(4000);
948
+ ```
949
+
950
+ ### Middleware Options
951
+
952
+ ```javascript
953
+ const middleware = createAuthMiddleware(permissions, {
954
+ defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
955
+ debug: false, // Enable debug logging
956
+ });
957
+ ```
958
+
959
+ | Option | Type | Default | Description |
960
+ |--------|------|---------|-------------|
961
+ | `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
962
+ | `debug` | `boolean` | `false` | Log authorization decisions |
963
+
964
+ ### Error Handling
965
+
966
+ The auth middleware uses Simfinity error classes:
967
+
968
+ ```javascript
969
+ const { auth } = require('@simtlix/simfinity-js');
970
+
971
+ const { UnauthenticatedError, ForbiddenError } = auth;
972
+
973
+ // UnauthenticatedError: code 'UNAUTHENTICATED', status 401
974
+ // ForbiddenError: code 'FORBIDDEN', status 403
975
+ ```
976
+
977
+ Custom error handling in rules:
978
+
979
+ ```javascript
980
+ const permissions = {
981
+ Mutation: {
982
+ deleteAccount: async (parent, args, ctx) => {
983
+ if (!ctx.user) {
984
+ throw new auth.UnauthenticatedError('Please log in');
985
+ }
986
+ if (ctx.user.role !== 'ADMIN' && ctx.user.id !== args.id) {
987
+ throw new auth.ForbiddenError('Cannot delete other users');
988
+ }
989
+ return true;
990
+ },
991
+ },
992
+ };
993
+ ```
994
+
995
+ ### Best Practices
996
+
997
+ 1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
998
+ 2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
999
+ 3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
1000
+ 4. **Fail closed**: Custom rules should deny on unexpected conditions
1001
+ 5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
1002
+ 6. **Test thoroughly**: Auth rules are critical - test all scenarios
1003
+
625
1004
  ## 🔗 Relationships
626
1005
 
627
1006
  ### Defining Relationships
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "optionalDependencies": {
37
37
  "graphql": "^16.11.0",
38
+ "graphql-middleware": "^6.1.35",
38
39
  "mongoose": "^8.16.2"
39
40
  }
40
41
  }
@@ -0,0 +1,44 @@
1
+ import SimfinityError from '../errors/simfinity.error.js';
2
+
3
+ /**
4
+ * Authentication error - thrown when user is not authenticated
5
+ * Uses code: UNAUTHENTICATED, status: 401
6
+ */
7
+ export class UnauthenticatedError extends SimfinityError {
8
+ constructor(message = 'Authentication required') {
9
+ super(message, 'UNAUTHENTICATED', 401);
10
+ this.name = 'UnauthenticatedError';
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Authorization error - thrown when user lacks permission
16
+ * Uses code: FORBIDDEN, status: 403
17
+ */
18
+ export class ForbiddenError extends SimfinityError {
19
+ constructor(message = 'Access denied') {
20
+ super(message, 'FORBIDDEN', 403);
21
+ this.name = 'ForbiddenError';
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Generic auth error factory for custom auth failures
27
+ * @param {string} message - Error message
28
+ * @param {string} code - Error code (UNAUTHENTICATED or FORBIDDEN)
29
+ * @returns {SimfinityError}
30
+ */
31
+ export const createAuthError = (message, code = 'FORBIDDEN') => {
32
+ const status = code === 'UNAUTHENTICATED' ? 401 : 403;
33
+ return new SimfinityError(message, code, status);
34
+ };
35
+
36
+ // Export all errors as an object for convenience
37
+ const errors = {
38
+ UnauthenticatedError,
39
+ ForbiddenError,
40
+ createAuthError,
41
+ };
42
+
43
+ export default errors;
44
+
@@ -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,312 @@
1
+ /**
2
+ * Simfinity GraphQL Authorization Middleware
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 { applyMiddleware } from 'graphql-middleware';
14
+ *
15
+ * const { createAuthMiddleware, 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 authMiddleware = createAuthMiddleware(permissions, { defaultPolicy: 'DENY' });
32
+ * const schemaWithAuth = applyMiddleware(schema, authMiddleware);
33
+ */
34
+
35
+ import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
36
+ import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
37
+ import {
38
+ resolvePath,
39
+ requireAuth,
40
+ requireRole,
41
+ requirePermission,
42
+ composeRules,
43
+ anyRule,
44
+ isOwner,
45
+ createRule,
46
+ allow,
47
+ deny,
48
+ } from './rules.js';
49
+
50
+ // Re-export errors
51
+ export { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
52
+
53
+ // Re-export expression utilities
54
+ export { evaluateExpression, isPolicyExpression, createRuleFromExpression } from './expressions.js';
55
+
56
+ // Re-export rule helpers and utilities
57
+ export {
58
+ resolvePath,
59
+ requireAuth,
60
+ requireRole,
61
+ requirePermission,
62
+ composeRules,
63
+ anyRule,
64
+ isOwner,
65
+ createRule,
66
+ allow,
67
+ deny,
68
+ } from './rules.js';
69
+
70
+ /**
71
+ * @typedef {'ALLOW' | 'DENY'} DefaultPolicy
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} AuthMiddlewareOptions
76
+ * @property {DefaultPolicy} [defaultPolicy='DENY'] - Default policy when no rule matches
77
+ * @property {boolean} [debug=false] - Enable debug logging
78
+ */
79
+
80
+ /**
81
+ * @typedef {Function} RuleFunction
82
+ * @param {*} parent - Parent resolver result
83
+ * @param {Object} args - GraphQL arguments
84
+ * @param {Object} ctx - GraphQL context
85
+ * @param {Object} info - GraphQL resolve info
86
+ * @returns {boolean|void|Promise<boolean|void>} - true/void to allow, false to deny
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object|RuleFunction|Array<RuleFunction>} Rule
91
+ */
92
+
93
+ /**
94
+ * @typedef {Object.<string, Rule>} TypePermissions
95
+ */
96
+
97
+ /**
98
+ * @typedef {Object.<string, TypePermissions>} PermissionSchema
99
+ */
100
+
101
+ /**
102
+ * Normalizes a rule to always be an array of functions
103
+ * @param {Rule} rule - The rule to normalize
104
+ * @returns {Function[]} Array of rule functions
105
+ */
106
+ const normalizeRule = (rule) => {
107
+ if (typeof rule === 'function') {
108
+ return [rule];
109
+ }
110
+
111
+ if (Array.isArray(rule)) {
112
+ return rule.flatMap(r => normalizeRule(r));
113
+ }
114
+
115
+ if (isPolicyExpression(rule)) {
116
+ return [createRuleFromExpression(rule)];
117
+ }
118
+
119
+ // Unknown rule type - return empty (will use default policy)
120
+ return [];
121
+ };
122
+
123
+ /**
124
+ * Gets the rule for a specific field, with wildcard fallback
125
+ * @param {PermissionSchema} permissions - The permission schema
126
+ * @param {string} typeName - The GraphQL type name
127
+ * @param {string} fieldName - The field name
128
+ * @returns {Function[]|null} Array of rule functions or null if no rule found
129
+ */
130
+ const getFieldRules = (permissions, typeName, fieldName) => {
131
+ const typePerms = permissions[typeName];
132
+
133
+ if (!typePerms) {
134
+ return null;
135
+ }
136
+
137
+ // Check for exact field rule first
138
+ if (fieldName in typePerms) {
139
+ return normalizeRule(typePerms[fieldName]);
140
+ }
141
+
142
+ // Fallback to wildcard
143
+ if ('*' in typePerms) {
144
+ return normalizeRule(typePerms['*']);
145
+ }
146
+
147
+ return null;
148
+ };
149
+
150
+ /**
151
+ * Executes a single rule
152
+ * @param {Function} rule - The rule function to execute
153
+ * @param {*} parent - Parent resolver result
154
+ * @param {Object} args - GraphQL arguments
155
+ * @param {Object} ctx - GraphQL context
156
+ * @param {Object} info - GraphQL resolve info
157
+ * @returns {Promise<boolean>} True if allowed, false if denied
158
+ */
159
+ const executeRule = async (rule, parent, args, ctx, info) => {
160
+ const result = await rule(parent, args, ctx, info);
161
+
162
+ // void/undefined/true means allow
163
+ if (result === undefined || result === true) {
164
+ return true;
165
+ }
166
+
167
+ // false means deny
168
+ return false;
169
+ };
170
+
171
+ /**
172
+ * Creates a graphql-middleware compatible authorization middleware
173
+ *
174
+ * @param {PermissionSchema} permissions - The permission schema object
175
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
176
+ * @returns {Function} A graphql-middleware compatible middleware function
177
+ *
178
+ * @example
179
+ * const permissions = {
180
+ * Query: {
181
+ * users: requireAuth(),
182
+ * adminDashboard: requireRole('ADMIN')
183
+ * },
184
+ * User: {
185
+ * '*': requireAuth(),
186
+ * email: requireRole('ADMIN')
187
+ * },
188
+ * Post: {
189
+ * content: {
190
+ * anyOf: [
191
+ * { eq: [{ ref: 'parent.published' }, true] },
192
+ * { eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] }
193
+ * ]
194
+ * }
195
+ * }
196
+ * };
197
+ *
198
+ * const middleware = createAuthMiddleware(permissions, { defaultPolicy: 'DENY' });
199
+ */
200
+ export const createAuthMiddleware = (permissions, options = {}) => {
201
+ const {
202
+ defaultPolicy = 'DENY',
203
+ debug = false,
204
+ } = options;
205
+
206
+ const log = debug ? console.log.bind(console, '[auth]') : () => {};
207
+
208
+ /**
209
+ * The middleware generator function
210
+ * Returns a middleware object keyed by type name, each containing field resolvers
211
+ */
212
+ return async (resolve, parent, args, ctx, info) => {
213
+ const typeName = info.parentType.name;
214
+ const fieldName = info.fieldName;
215
+
216
+ log(`Checking ${typeName}.${fieldName}`);
217
+
218
+ // Get rules for this field
219
+ const rules = getFieldRules(permissions, typeName, fieldName);
220
+
221
+ // If no rules found, apply default policy
222
+ if (rules === null || rules.length === 0) {
223
+ log(`No rules for ${typeName}.${fieldName}, applying default policy: ${defaultPolicy}`);
224
+
225
+ if (defaultPolicy === 'DENY') {
226
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
227
+ }
228
+
229
+ // ALLOW - proceed to resolver
230
+ return resolve(parent, args, ctx, info);
231
+ }
232
+
233
+ // Execute all rules (AND logic - all must pass)
234
+ for (const rule of rules) {
235
+ log(`Executing rule for ${typeName}.${fieldName}`);
236
+
237
+ const allowed = await executeRule(rule, parent, args, ctx, info);
238
+
239
+ if (!allowed) {
240
+ log(`Rule denied access to ${typeName}.${fieldName}`);
241
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
242
+ }
243
+ }
244
+
245
+ log(`Access granted to ${typeName}.${fieldName}`);
246
+
247
+ // All rules passed - proceed to resolver
248
+ return resolve(parent, args, ctx, info);
249
+ };
250
+ };
251
+
252
+ /**
253
+ * Creates a field-level middleware object from a permission schema
254
+ * This can be used with graphql-middleware's applyMiddleware
255
+ *
256
+ * @param {PermissionSchema} permissions - The permission schema
257
+ * @param {AuthMiddlewareOptions} [options={}] - Middleware options
258
+ * @returns {Object} Field middleware object compatible with graphql-middleware
259
+ */
260
+ export const createFieldMiddleware = (permissions, options = {}) => {
261
+ const middleware = createAuthMiddleware(permissions, options);
262
+ const fieldMiddleware = {};
263
+
264
+ for (const typeName of Object.keys(permissions)) {
265
+ fieldMiddleware[typeName] = {};
266
+
267
+ const typePerms = permissions[typeName];
268
+ for (const fieldName of Object.keys(typePerms)) {
269
+ if (fieldName === '*') {
270
+ // Wildcard rules are handled by the middleware internally
271
+ continue;
272
+ }
273
+ fieldMiddleware[typeName][fieldName] = middleware;
274
+ }
275
+ }
276
+
277
+ return fieldMiddleware;
278
+ };
279
+
280
+ // Default export with all auth utilities
281
+ const auth = {
282
+ // Main factory
283
+ createAuthMiddleware,
284
+ createFieldMiddleware,
285
+
286
+ // Utilities
287
+ resolvePath,
288
+
289
+ // Rule helpers
290
+ requireAuth,
291
+ requireRole,
292
+ requirePermission,
293
+ composeRules,
294
+ anyRule,
295
+ isOwner,
296
+ createRule,
297
+ allow,
298
+ deny,
299
+
300
+ // Expression utilities
301
+ evaluateExpression,
302
+ isPolicyExpression,
303
+ createRuleFromExpression,
304
+
305
+ // Errors
306
+ UnauthenticatedError,
307
+ ForbiddenError,
308
+ createAuthError,
309
+ };
310
+
311
+ export default auth;
312
+
@@ -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
+
package/src/index.js CHANGED
@@ -2144,6 +2144,7 @@ export { createValidatedScalar };
2144
2144
  export { default as validators } from './validators.js';
2145
2145
  export { default as scalars } from './scalars.js';
2146
2146
  export { default as plugins } from './plugins.js';
2147
+ export { default as auth } from './auth/index.js';
2147
2148
 
2148
2149
  const createArgsForQuery = (argTypes) => {
2149
2150
  const argsObject = {};
package/src/plugins.js CHANGED
@@ -13,9 +13,9 @@ export const apolloCountPlugin = () => {
13
13
  count: contextValue.count,
14
14
  };
15
15
  }
16
- }
16
+ },
17
17
  };
18
- }
18
+ },
19
19
  };
20
20
  };
21
21
 
@@ -31,12 +31,12 @@ export const envelopCountPlugin = () => {
31
31
  if (args.contextValue?.count) {
32
32
  result.extensions = {
33
33
  ...result.extensions,
34
- count: args.contextValue.count
34
+ count: args.contextValue.count,
35
35
  };
36
36
  }
37
- }
37
+ },
38
38
  };
39
- }
39
+ },
40
40
  };
41
41
  };
42
42