@stonecrop/casl-middleware 0.7.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.
Files changed (66) hide show
  1. package/README.md +149 -0
  2. package/dist/casl-middleware.d.ts +158 -0
  3. package/dist/casl-middleware.js +40571 -0
  4. package/dist/casl-middleware.js.map +1 -0
  5. package/dist/casl_middleware.tsbuildinfo +1 -0
  6. package/dist/src/index.d.ts +6 -0
  7. package/dist/src/index.d.ts.map +1 -0
  8. package/dist/src/index.js +3 -0
  9. package/dist/src/middleware/ability.d.ts +55 -0
  10. package/dist/src/middleware/ability.d.ts.map +1 -0
  11. package/dist/src/middleware/ability.js +139 -0
  12. package/dist/src/middleware/graphql.d.ts +11 -0
  13. package/dist/src/middleware/graphql.d.ts.map +1 -0
  14. package/dist/src/middleware/graphql.js +120 -0
  15. package/dist/src/middleware/introspection.d.ts +71 -0
  16. package/dist/src/middleware/introspection.d.ts.map +1 -0
  17. package/dist/src/middleware/introspection.js +169 -0
  18. package/dist/src/middleware/jwt.d.ts +114 -0
  19. package/dist/src/middleware/jwt.d.ts.map +1 -0
  20. package/dist/src/middleware/jwt.js +291 -0
  21. package/dist/src/middleware/postgraphile.d.ts +7 -0
  22. package/dist/src/middleware/postgraphile.d.ts.map +1 -0
  23. package/dist/src/middleware/postgraphile.js +80 -0
  24. package/dist/src/middleware/yoga.d.ts +15 -0
  25. package/dist/src/middleware/yoga.d.ts.map +1 -0
  26. package/dist/src/middleware/yoga.js +32 -0
  27. package/dist/src/tsdoc-metadata.json +11 -0
  28. package/dist/src/types/index.d.ts +114 -0
  29. package/dist/src/types/index.d.ts.map +1 -0
  30. package/dist/src/types/index.js +0 -0
  31. package/dist/tests/ability.test.d.ts +2 -0
  32. package/dist/tests/ability.test.d.ts.map +1 -0
  33. package/dist/tests/ability.test.js +125 -0
  34. package/dist/tests/helpers/test-utils.d.ts +46 -0
  35. package/dist/tests/helpers/test-utils.d.ts.map +1 -0
  36. package/dist/tests/helpers/test-utils.js +92 -0
  37. package/dist/tests/introspection.test.d.ts +2 -0
  38. package/dist/tests/introspection.test.d.ts.map +1 -0
  39. package/dist/tests/introspection.test.js +368 -0
  40. package/dist/tests/jwt.test.d.ts +2 -0
  41. package/dist/tests/jwt.test.d.ts.map +1 -0
  42. package/dist/tests/jwt.test.js +371 -0
  43. package/dist/tests/middleware.test.d.ts +2 -0
  44. package/dist/tests/middleware.test.d.ts.map +1 -0
  45. package/dist/tests/middleware.test.js +184 -0
  46. package/dist/tests/postgraphile-plugin.test.d.ts +2 -0
  47. package/dist/tests/postgraphile-plugin.test.d.ts.map +1 -0
  48. package/dist/tests/postgraphile-plugin.test.js +56 -0
  49. package/dist/tests/setup.d.ts +2 -0
  50. package/dist/tests/setup.d.ts.map +1 -0
  51. package/dist/tests/setup.js +11 -0
  52. package/dist/tests/user-roles.test.d.ts +2 -0
  53. package/dist/tests/user-roles.test.d.ts.map +1 -0
  54. package/dist/tests/user-roles.test.js +157 -0
  55. package/dist/tests/yoga-plugin.test.d.ts +2 -0
  56. package/dist/tests/yoga-plugin.test.d.ts.map +1 -0
  57. package/dist/tests/yoga-plugin.test.js +47 -0
  58. package/package.json +91 -0
  59. package/src/index.ts +15 -0
  60. package/src/middleware/ability.ts +191 -0
  61. package/src/middleware/graphql.ts +157 -0
  62. package/src/middleware/introspection.ts +258 -0
  63. package/src/middleware/jwt.ts +394 -0
  64. package/src/middleware/postgraphile.ts +93 -0
  65. package/src/middleware/yoga.ts +39 -0
  66. package/src/types/index.ts +133 -0
@@ -0,0 +1,157 @@
1
+ import { PureAbility, AbilityBuilder } from '@casl/ability';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { createCaslMiddleware } from '../src/middleware/graphql';
4
+ const Ability = PureAbility;
5
+ // Helper to create abilities with proper matchers
6
+ const createTestAbility = (rules) => {
7
+ const { can, cannot, build } = new AbilityBuilder(Ability);
8
+ rules.forEach(rule => {
9
+ if (rule.inverted) {
10
+ if (rule.fields && rule.conditions) {
11
+ cannot(rule.action, rule.subject, rule.fields, rule.conditions);
12
+ }
13
+ else if (rule.fields) {
14
+ cannot(rule.action, rule.subject, rule.fields);
15
+ }
16
+ else if (rule.conditions) {
17
+ cannot(rule.action, rule.subject, rule.conditions);
18
+ }
19
+ else {
20
+ cannot(rule.action, rule.subject);
21
+ }
22
+ }
23
+ else {
24
+ if (rule.fields && rule.conditions) {
25
+ can(rule.action, rule.subject, rule.fields, rule.conditions);
26
+ }
27
+ else if (rule.fields) {
28
+ can(rule.action, rule.subject, rule.fields);
29
+ }
30
+ else if (rule.conditions) {
31
+ can(rule.action, rule.subject, rule.conditions);
32
+ }
33
+ else {
34
+ can(rule.action, rule.subject);
35
+ }
36
+ }
37
+ });
38
+ return build({
39
+ detectSubjectType: (object) => {
40
+ // Handle CASL's subject() helper objects
41
+ if (object && typeof object === 'object') {
42
+ // CASL uses __caslSubjectType__ property
43
+ if ('__caslSubjectType__' in object) {
44
+ return object.__caslSubjectType__;
45
+ }
46
+ // Fallback to other type indicators
47
+ if (object.type)
48
+ return object.type;
49
+ if (object.constructor?.name && object.constructor.name !== 'Object') {
50
+ return object.constructor.name;
51
+ }
52
+ }
53
+ return 'Unknown';
54
+ },
55
+ fieldMatcher: fields => field => {
56
+ if (!fields || !field)
57
+ return false;
58
+ return Array.isArray(fields) ? fields.includes(field) : fields === field;
59
+ },
60
+ conditionsMatcher: conditions => object => {
61
+ if (!conditions || !object)
62
+ return true;
63
+ return Object.keys(conditions).every(key => object[key] === conditions[key]);
64
+ },
65
+ });
66
+ };
67
+ describe('CASL GraphQL Middleware', () => {
68
+ let mockResolve;
69
+ let mockContext;
70
+ let mockInfo;
71
+ beforeEach(() => {
72
+ mockResolve = vi.fn().mockResolvedValue({ data: 'test' });
73
+ mockContext = {
74
+ ability: createTestAbility([
75
+ { action: 'read', subject: 'Query' },
76
+ { action: 'read', subject: 'User' },
77
+ { action: 'update', subject: 'User', conditions: { id: '123' } },
78
+ ]),
79
+ user: { id: '123', roles: ['user'] },
80
+ };
81
+ mockInfo = {
82
+ fieldName: 'getUser',
83
+ parentType: { name: 'Query' },
84
+ path: { typename: 'Query', key: 'getUser' },
85
+ operation: {
86
+ operation: 'query',
87
+ loc: { source: { body: 'query { getUser { id } }' } },
88
+ },
89
+ };
90
+ });
91
+ describe('Basic Middleware Functionality', () => {
92
+ it('should pass through when ability allows the operation', async () => {
93
+ const middleware = createCaslMiddleware();
94
+ // Don't pass args that would be treated as conditions
95
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
96
+ expect(mockResolve).toHaveBeenCalled();
97
+ expect(result).toEqual({ data: 'test' });
98
+ });
99
+ it('should create ability when not in context', async () => {
100
+ const middleware = createCaslMiddleware();
101
+ const contextWithoutAbility = { user: { id: '123', roles: ['user'] } };
102
+ // The middleware creates a default ability if none exists
103
+ // Default ability only allows 'read' on 'Query'
104
+ const result = await middleware(mockResolve, {}, {}, contextWithoutAbility, mockInfo);
105
+ // Should work because default ability allows 'read' on 'Query'
106
+ expect(mockResolve).toHaveBeenCalled();
107
+ expect(result).toEqual({ data: 'test' });
108
+ expect(contextWithoutAbility.ability).toBeDefined();
109
+ });
110
+ it('should deny access when ability does not allow operation', async () => {
111
+ const middleware = createCaslMiddleware();
112
+ // Create ability that doesn't allow 'read' on 'Query'
113
+ mockContext.ability = createTestAbility([{ action: 'read', subject: 'Post' }]);
114
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Access denied for read on Query');
115
+ });
116
+ });
117
+ describe('Action Mapping', () => {
118
+ it('should map GraphQL operations to custom actions', async () => {
119
+ const middleware = createCaslMiddleware({
120
+ actionMap: {
121
+ query: 'view',
122
+ mutation: 'modify',
123
+ subscription: 'watch',
124
+ },
125
+ });
126
+ // Update ability to allow 'view' instead of 'read'
127
+ mockContext.ability = createTestAbility([{ action: 'view', subject: 'Query' }]);
128
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
129
+ expect(mockResolve).toHaveBeenCalled();
130
+ expect(result).toEqual({ data: 'test' });
131
+ });
132
+ it('should deny when custom action is not allowed', async () => {
133
+ const middleware = createCaslMiddleware({
134
+ actionMap: {
135
+ query: 'view',
136
+ },
137
+ });
138
+ // Ability still has 'read' permission, not 'view'
139
+ await expect(middleware(mockResolve, {}, {}, mockContext, mockInfo)).rejects.toThrow('Access denied for view on Query');
140
+ });
141
+ });
142
+ describe('Subject Conditions', () => {
143
+ it('should handle conditional permissions correctly', async () => {
144
+ const middleware = createCaslMiddleware();
145
+ // Change to User type which has conditional permission
146
+ mockInfo.parentType = { name: 'User' };
147
+ mockInfo.path = { typename: 'User', key: 'updateUser' };
148
+ mockInfo.operation.operation = 'mutation';
149
+ // Ability allows update on User with id: '123'
150
+ mockContext.ability = createTestAbility([{ action: 'update', subject: 'User', conditions: { id: '123' } }]);
151
+ // This should work because we're not passing the conditions through args
152
+ // The middleware should check the general permission
153
+ const result = await middleware(mockResolve, {}, {}, mockContext, mockInfo);
154
+ expect(mockResolve).toHaveBeenCalled();
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=yoga-plugin.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"yoga-plugin.test.d.ts","sourceRoot":"","sources":["../../tests/yoga-plugin.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { yogaCaslPlugin } from '../src/middleware/yoga';
3
+ describe('Yoga CASL Plugin', () => {
4
+ it('should be a valid Yoga plugin', () => {
5
+ expect(yogaCaslPlugin).toBeDefined();
6
+ expect(typeof yogaCaslPlugin).toBe('object');
7
+ expect(yogaCaslPlugin).toHaveProperty('onContextBuilding');
8
+ });
9
+ it('should extend context with ability and user', async () => {
10
+ const mockContext = {};
11
+ const extendContext = vi.fn(extension => {
12
+ Object.assign(mockContext, extension);
13
+ });
14
+ const contextParams = {
15
+ context: mockContext,
16
+ extendContext,
17
+ };
18
+ // Execute the plugin
19
+ await yogaCaslPlugin.onContextBuilding(contextParams);
20
+ expect(extendContext).toHaveBeenCalled();
21
+ expect(extendContext).toHaveBeenCalledWith(expect.objectContaining({
22
+ ability: expect.any(Object),
23
+ user: expect.objectContaining({
24
+ id: '1',
25
+ roles: ['editor'],
26
+ }),
27
+ }));
28
+ });
29
+ it('should create ability with editor permissions', async () => {
30
+ const mockContext = {};
31
+ let extendedData = {};
32
+ const extendContext = vi.fn(extension => {
33
+ extendedData = extension;
34
+ });
35
+ const contextParams = {
36
+ context: mockContext,
37
+ extendContext,
38
+ };
39
+ await yogaCaslPlugin.onContextBuilding(contextParams);
40
+ // Check that the ability was created with editor permissions
41
+ expect(extendedData.ability).toBeDefined();
42
+ expect(extendedData.user.roles).toContain('editor');
43
+ // The ability should have the permissions we expect for an editor
44
+ const ability = extendedData.ability;
45
+ expect(ability.can('read', 'Query')).toBe(true);
46
+ });
47
+ });
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@stonecrop/casl-middleware",
3
+ "version": "0.7.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "homepage": "https://github.com/agritheory/stonecrop#readme",
7
+ "author": {
8
+ "name": "Tyler Matteson",
9
+ "email": "support@agritheory.dev"
10
+ },
11
+ "description": "CASL authorization middleware for GraphQL servers",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/agritheory/stonecrop",
15
+ "directory": "casl_middleware"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/agritheory/stonecrop/issues"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "import": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "default": "./dist/casl_middleware.js"
25
+ }
26
+ },
27
+ "./types": {
28
+ "import": "./dist/src/types/index.d.ts",
29
+ "require": "./dist/src/types/index.d.ts"
30
+ }
31
+ },
32
+ "typings": "./dist/src/index.d.ts",
33
+ "files": [
34
+ "dist/*",
35
+ "src/*"
36
+ ],
37
+ "keywords": [
38
+ "casl",
39
+ "graphql",
40
+ "authorization",
41
+ "middleware",
42
+ "postgraphile",
43
+ "rockfoil"
44
+ ],
45
+ "dependencies": {
46
+ "@casl/ability": "^6.7.5",
47
+ "graphql": "^16.12.0",
48
+ "graphql-tag": "^2.12.6",
49
+ "jsonwebtoken": "^9.0.3"
50
+ },
51
+ "devDependencies": {
52
+ "@graphql-tools/merge": "^9.1.7",
53
+ "@graphql-tools/schema": "^10.0.31",
54
+ "@graphql-tools/utils": "^11.0.0",
55
+ "@rushstack/heft": "^1.1.7",
56
+ "@types/node": "^22.19.5",
57
+ "@vitest/coverage-istanbul": "^4.0.17",
58
+ "eslint": "^9.39.2",
59
+ "graphql-middleware": "^6.1.35",
60
+ "graphql-yoga": "^5.18.0",
61
+ "jsdom": "^27.4.0",
62
+ "postgraphile": "^5.0.0-rc.3",
63
+ "typescript": "^5.9.3",
64
+ "vite": "^7.3.1",
65
+ "vitest": "^4.0.17",
66
+ "@types/jsonwebtoken": "~9.0.10",
67
+ "stonecrop-rig": "0.2.22"
68
+ },
69
+ "peerDependencies": {
70
+ "graphql-yoga": "^5.18.0",
71
+ "postgraphile": "^5.0.0-rc.3"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "postgraphile": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "scripts": {
79
+ "_phase:build": "heft build && vite build && rushx docs",
80
+ "prepublish": "heft build && vite build && rushx docs",
81
+ "build": "heft build && vite build && rushx docs",
82
+ "dev": "vite",
83
+ "docs": "bash ../common/scripts/run-docs.sh aform",
84
+ "lint": "eslint .",
85
+ "preview": "vite preview",
86
+ "test": "vitest run --coverage.enabled false",
87
+ "test:watch": "vitest watch",
88
+ "test:coverage": "vitest run --coverage",
89
+ "test:ui": "vitest --ui"
90
+ }
91
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { createAbility, detectSubjectType } from './middleware/ability'
2
+ export type { AppAbility } from './middleware/ability'
3
+ export { createCaslMiddleware } from './middleware/graphql'
4
+ export { pglCaslPlugin } from './middleware/postgraphile'
5
+ export type {
6
+ User,
7
+ Context,
8
+ MiddlewareOptions,
9
+ FieldPermission,
10
+ ResolverFn,
11
+ MiddlewareFn,
12
+ PluginOptions,
13
+ AbilityResponse,
14
+ CreateAbilityInput,
15
+ } from './types'
@@ -0,0 +1,191 @@
1
+ import { AbilityBuilder, PureAbility, type AbilityClass, type FieldMatcher, type ConditionsMatcher } from '@casl/ability'
2
+
3
+ import type { Context } from '../types'
4
+
5
+ /**
6
+ * Detects the subject type from an object for CASL authorization
7
+ * @param object - The object to detect the subject type from
8
+ * @returns The subject type string
9
+ * @public
10
+ */
11
+ export const detectSubjectType = (object: any): string => {
12
+ // Handle CASL's subject() helper objects
13
+ if (object && typeof object === 'object') {
14
+ // CASL uses __caslSubjectType__ property
15
+ if ('__caslSubjectType__' in object) {
16
+ return object.__caslSubjectType__
17
+ }
18
+ // Fallback to other type indicators
19
+ if (object.type) return object.type
20
+ if (object.constructor?.name && object.constructor.name !== 'Object') {
21
+ return object.constructor.name
22
+ }
23
+ }
24
+ return 'Unknown'
25
+ }
26
+
27
+ // Simple field matcher - checks if a field is in the restricted list
28
+ const fieldMatcher: FieldMatcher = fields => field => {
29
+ if (!fields || !field) return false
30
+ if (Array.isArray(fields)) {
31
+ return fields.includes(field)
32
+ }
33
+ return fields === field
34
+ }
35
+
36
+ // Simple conditions matcher - basic equality check
37
+ const conditionsMatcher: ConditionsMatcher<any> = conditions => object => {
38
+ if (!conditions || !object) return true
39
+
40
+ return Object.keys(conditions).every(key => {
41
+ const conditionValue = conditions[key]
42
+ const objectValue = object[key]
43
+
44
+ // Simple equality check
45
+ return objectValue === conditionValue
46
+ })
47
+ }
48
+
49
+ /**
50
+ * CASL ability type for authorization with flexible subject types
51
+ * @public
52
+ */
53
+ export type AppAbility = PureAbility<[string, any], any>
54
+
55
+ /**
56
+ * Function type for building user abilities
57
+ * @public
58
+ */
59
+ export type AbilityBuilderFunction = (user?: Context['user']) => Promise<AppAbility> | AppAbility
60
+
61
+ const Ability = PureAbility as AbilityClass<AppAbility>
62
+
63
+ /**
64
+ * Default ability builder - only provides minimal public access
65
+ */
66
+ export const defaultAbilityBuilder = (user?: Context['user']): AppAbility => {
67
+ const { can, build } = new AbilityBuilder<AppAbility>(Ability)
68
+
69
+ can('read', 'Query')
70
+
71
+ return build({
72
+ detectSubjectType,
73
+ fieldMatcher,
74
+ conditionsMatcher,
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Create ability using a provided builder function
80
+ * @param user - User information for building the ability
81
+ * @param builderFn - Function to build the ability
82
+ * @returns Promise resolving to the created ability
83
+ * @public
84
+ */
85
+ export const createAbility = async (
86
+ user?: Context['user'],
87
+ builderFn: AbilityBuilderFunction = defaultAbilityBuilder
88
+ ): Promise<AppAbility> => {
89
+ return await builderFn(user)
90
+ }
91
+
92
+ /**
93
+ * Factory function for creating custom ability builders
94
+ */
95
+ export const createDatabaseAbilityBuilder = (
96
+ fetchRules: (userId?: string) => Promise<any[]>
97
+ ): AbilityBuilderFunction => {
98
+ return async (user?: Context['user']) => {
99
+ const { can, cannot, build } = new AbilityBuilder<AppAbility>(Ability)
100
+
101
+ if (user?.id) {
102
+ const rules = await fetchRules(user.id)
103
+
104
+ rules.forEach(rule => {
105
+ if (rule.inverted) {
106
+ cannot(rule.action, rule.subject, rule.fields, rule.conditions)
107
+ } else {
108
+ can(rule.action, rule.subject, rule.fields, rule.conditions)
109
+ }
110
+ })
111
+ } else {
112
+ can('read', 'Query')
113
+ }
114
+
115
+ return build({
116
+ detectSubjectType,
117
+ fieldMatcher,
118
+ conditionsMatcher,
119
+ })
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Create an ability builder from static configuration
125
+ */
126
+ export const createConfigBasedAbilityBuilder = (config: {
127
+ roles: Record<
128
+ string,
129
+ Array<{
130
+ action: string | string[]
131
+ subject: string
132
+ fields?: string | string[]
133
+ conditions?: any
134
+ inverted?: boolean
135
+ }>
136
+ >
137
+ defaultRules?: Array<{
138
+ action: string | string[]
139
+ subject: string
140
+ fields?: string | string[]
141
+ conditions?: any
142
+ }>
143
+ }): AbilityBuilderFunction => {
144
+ return (user?: Context['user']) => {
145
+ const { can, cannot, build } = new AbilityBuilder<AppAbility>(Ability)
146
+
147
+ // Apply default rules
148
+ if (config.defaultRules) {
149
+ config.defaultRules.forEach(rule => {
150
+ can(rule.action, rule.subject, rule.fields, rule.conditions)
151
+ })
152
+ }
153
+
154
+ // Apply role-based rules
155
+ if (user?.roles) {
156
+ user.roles.forEach(role => {
157
+ const rules = config.roles[role]
158
+ if (rules) {
159
+ rules.forEach(rule => {
160
+ // Process template variables in conditions
161
+ let processedConditions = rule.conditions
162
+ if (processedConditions && user.id) {
163
+ const condStr = JSON.stringify(processedConditions)
164
+ if (condStr.includes('{{userId}}')) {
165
+ processedConditions = JSON.parse(condStr.replace(/{{userId}}/g, user.id))
166
+ }
167
+ }
168
+
169
+ if (rule.inverted) {
170
+ cannot(rule.action, rule.subject, rule.fields, processedConditions)
171
+ } else {
172
+ can(rule.action, rule.subject, rule.fields, processedConditions)
173
+ }
174
+ })
175
+ }
176
+ })
177
+ }
178
+
179
+ return build({
180
+ detectSubjectType,
181
+ fieldMatcher,
182
+ conditionsMatcher,
183
+ })
184
+ }
185
+ }
186
+
187
+ export const createAbilityFactory = <T extends PureAbility>(
188
+ builderFn: (user?: Context['user']) => T
189
+ ): ((user?: Context['user']) => T) => {
190
+ return (user?: Context['user']) => builderFn(user)
191
+ }
@@ -0,0 +1,157 @@
1
+ import { subject } from '@casl/ability'
2
+ import { GraphQLError, GraphQLResolveInfo } from 'graphql'
3
+ import { parse, visit, FieldNode, DocumentNode } from 'graphql/language'
4
+ import { Context, MiddlewareOptions, ResolverFn } from '../types'
5
+ import { createAbility, defaultAbilityBuilder } from './ability'
6
+
7
+ // Helper to extract operation name from GraphQL info
8
+ const getOperationName = (info: GraphQLResolveInfo): string => {
9
+ return info.operation.operation.toLowerCase()
10
+ }
11
+
12
+ // Helper to get full field path
13
+ const getFieldPath = (info: GraphQLResolveInfo): string => {
14
+ return info.path.typename ? `${info.path.typename}.${info.path.key}` : String(info.path.key)
15
+ }
16
+
17
+ // Parse GraphQL query to extract accessed fields
18
+ const extractAccessedFields = (query: string): string[] => {
19
+ const fields: string[] = []
20
+
21
+ try {
22
+ const ast: DocumentNode = parse(query)
23
+
24
+ visit(ast, {
25
+ Field: {
26
+ enter(node: FieldNode) {
27
+ if (node.name.value !== '__typename') {
28
+ fields.push(node.name.value)
29
+ }
30
+ },
31
+ },
32
+ })
33
+ } catch (error) {
34
+ console.error('Failed to parse GraphQL query:', error)
35
+ }
36
+
37
+ return fields
38
+ }
39
+
40
+ // Check if ability allows the operation
41
+ const checkPermission = (context: Context, action: string, subjectName: string, conditions?: any): boolean => {
42
+ if (!context.ability) {
43
+ return false
44
+ }
45
+
46
+ try {
47
+ // Only use subject helper if we have specific conditions to check
48
+ // (not just resolver args, but actual CASL conditions)
49
+ if (conditions && typeof conditions === 'object') {
50
+ return context.ability.can(action, subject(subjectName, conditions))
51
+ } else {
52
+ // Simple check without conditions - just action and subject type
53
+ return context.ability.can(action, subjectName)
54
+ }
55
+ } catch (error) {
56
+ console.error('Permission check failed:', error)
57
+ return false
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Creates CASL authorization middleware for GraphQL resolvers
63
+ * @param options - Configuration options for the middleware
64
+ * @returns Middleware function that wraps resolvers with authorization checks
65
+ * @public
66
+ */
67
+ export const createCaslMiddleware = (options: MiddlewareOptions = {}) => {
68
+ const {
69
+ subjectMap = {},
70
+ actionMap = {
71
+ query: 'read',
72
+ mutation: 'update',
73
+ subscription: 'read',
74
+ },
75
+ fieldPermissions = {},
76
+ abilityBuilder = defaultAbilityBuilder,
77
+ debug = false,
78
+ } = options
79
+
80
+ return async (
81
+ resolve: ResolverFn,
82
+ root: any,
83
+ args: any,
84
+ context: Context,
85
+ info: GraphQLResolveInfo
86
+ ): Promise<any> => {
87
+ // Create ability if not already in context
88
+ if (!context.ability) {
89
+ context.ability = await createAbility(context.user, abilityBuilder)
90
+ }
91
+
92
+ const operation = getOperationName(info)
93
+ const fieldPath = getFieldPath(info)
94
+ const action = actionMap[operation] || operation
95
+
96
+ // Get subject from map or default to type name
97
+ const subjectType = info.parentType.name
98
+ const subjectName = subjectMap[subjectType] || subjectType
99
+
100
+ if (debug) {
101
+ console.log(`Checking permission: ${action} on ${subjectName} at ${fieldPath}`)
102
+ }
103
+
104
+ // Check field-level permissions if defined
105
+ if (fieldPermissions[fieldPath]) {
106
+ const permissions = fieldPermissions[fieldPath]
107
+ const allowed = permissions.some(permission =>
108
+ // Don't pass resolver args here - only use them if the permission defines conditions
109
+ checkPermission(context, permission.action, permission.subject, permission.conditions)
110
+ )
111
+
112
+ if (!allowed) {
113
+ throw new GraphQLError(`Access denied for field ${fieldPath}`)
114
+ }
115
+ }
116
+
117
+ // Check operation-level permission
118
+ // Don't pass resolver args as conditions - they're not CASL conditions
119
+ const allowed = checkPermission(context, action, subjectName)
120
+
121
+ if (!allowed) {
122
+ throw new GraphQLError(`Access denied for ${action} on ${subjectName}`)
123
+ }
124
+
125
+ // Additional checks for mutations with input fields
126
+ if (operation === 'mutation' && args.input) {
127
+ const querySource = info.operation.loc?.source.body
128
+
129
+ if (querySource) {
130
+ const accessedFields = extractAccessedFields(querySource)
131
+
132
+ for (const field of accessedFields) {
133
+ // Check field-level update permissions if needed
134
+ const fieldAllowed = checkPermission(context, 'update', subjectName, { field })
135
+
136
+ if (!fieldAllowed && debug) {
137
+ console.warn(`Warning: Field ${field} update not explicitly allowed`)
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // Call the resolver
144
+ return resolve(root, args, context, info)
145
+ }
146
+ }
147
+
148
+ // Utility function to combine multiple middlewares
149
+ export const combineMiddlewares = (...middlewares: any[]) => {
150
+ return (resolve: ResolverFn, root: any, args: any, context: any, info: any) => {
151
+ const chain = middlewares.reduceRight(
152
+ (next, middleware) => () => middleware(next, root, args, context, info),
153
+ () => resolve(root, args, context, info)
154
+ )
155
+ return chain()
156
+ }
157
+ }