@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 +379 -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 +312 -0
- package/src/auth/rules.js +274 -0
- package/src/index.js +1 -0
- package/src/plugins.js +5 -5
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
|
+
"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
|
|