-
Notifications
You must be signed in to change notification settings - Fork 1
/
directives.js
237 lines (206 loc) · 7.1 KB
/
directives.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import { SchemaDirectiveVisitor } from 'graphql-tools'
import { defaultFieldResolver } from 'graphql'
import { Roles } from 'meteor/alanning:roles'
import { Meteor } from 'meteor/meteor'
/* TODO: What to do about this? 🤔
https://www.apollographql.com/docs/graphql-tools/schema-directives.html
"One drawback of this approach is that it does not guarantee fields
will be wrapped if they are added to the schema after AuthDirective
is applied"
*/
const POLARITY = {
ALLOW: 'allow',
DENY: 'deny',
}
function throwNotAuthorized() {
throw new Error('Not Authorized.')
}
function throwInvalidArgs(args) {
throw new Error('Invalid arguments for allow/deny directives.')
}
class RolesDirective extends SchemaDirectiveVisitor {
visitObject(objectType) {
const { roles, group } = this.args
if (!roles && !group) throwInvalidArgs({ roles, group })
objectType[`_${this.name}`] = { roles, group }
this.ensureFieldsWrapped(objectType)
}
// Visitor methods for nested types like fields and arguments
// also receive a details object that provides information about
// the parent and grandparent types.
visitFieldDefinition(field, details) {
const { roles, group } = this.args
if (!roles && !group) throwInvalidArgs({ roles, group })
field[`_${this.name}`] = { roles, group }
this.ensureFieldsWrapped(details.objectType)
}
visitInputFieldDefinition(field, { objectType }) {
/**
* Basically the way this works is:
* 1. For the given InputField on an InputType, find the mutations that use it
* 2. Then store the field name and allow/deny directive info on the mutation field
* 3. Wrap the mutation resolver with the permissions check
*
* Also, there are 2 optimizations at play:
* 1. Only wrap the mutation resolver once
* 2. Only check the unique set of permissions
*/
const { roles, group } = this.args
if (!roles && !group) throwInvalidArgs({ roles, group })
const typeName = objectType.name
const { schema } = this
const PROTECTED_INPUTS_KEY = `_allowDenyInputs`
const WRAPPED_KEY = `_allowDenyWrapped`
// find mutations using this InputType
const mutations = getMutationsWithArgType(schema, typeName)
// store the directive meta on each mutation
mutations.forEach(mutation => {
mutation[PROTECTED_INPUTS_KEY] = this.mergeProtectedFields(
mutation[PROTECTED_INPUTS_KEY],
field.name,
this.name,
this.args
)
// Ensure mutaion resolver is only wrapped once
if (mutation[WRAPPED_KEY]) return
mutation[WRAPPED_KEY] = true
const { resolve = defaultFieldResolver } = mutation
mutation.resolve = (...args) => {
// Get all protected fields in the input argument
const { input } = args[1]
const protectedFields = Object.keys(input).filter(inputKey => {
return mutation[PROTECTED_INPUTS_KEY].hasOwnProperty(inputKey)
})
// If there aren't any, move along...
if (!protectedFields.length) {
return resolve.apply(this, args)
}
// make sure there is a user
const context = args[2]
const userId = context.userId
if (!userId) throwNotAuthorized()
// To avoid repeating the same permission checks we need only the unique permissions
const permissionsToCheck = this.getUniquePermissionsToCheck(
mutation[PROTECTED_INPUTS_KEY],
protectedFields
)
// For each unique permission, check it
const { allow, deny } = permissionsToCheck
if (allow) {
allow.forEach(({ roles, group }) => {
// White list
if (!Roles.userIsInRole(userId, roles, group)) {
throwNotAuthorized()
}
})
}
if (deny) {
deny.forEach(({ roles, group }) => {
// Black list
if (Roles.userIsInRole(userId, roles, group)) {
throwNotAuthorized()
}
})
}
return resolve.apply(this, args)
}
})
}
/**
* Returns a new object with the merged protected fields
* @param {*} source Object to merge
* @param {string} fieldName
* @param {POLARITY} polarity
* @param {*} args
*/
mergeProtectedFields(source, fieldName, polarity, args) {
// Considering these properties may or may not exist yet,
// this is the structure we want:
// mutation = {
// [STATE_KEY]: {
// secretField: {
// deny: {
// roles: ['user']
// }
// }
// }
// }
if (!source) {
return { [fieldName]: { [polarity]: { ...args } } }
} else {
return {
...source,
[fieldName]: {
...source[fieldName],
[polarity]: { ...args },
},
}
}
}
getUniquePermissionsToCheck(permissions, inputs) {
const allowSet = new Set()
const denySet = new Set()
inputs.forEach(input => {
const { allow, deny } = permissions[input]
if (allow) allowSet.add(JSON.stringify(allow))
if (deny) denySet.add(JSON.stringify(deny))
})
return {
allow: allowSet.size ? [...allowSet].map(JSON.parse) : undefined,
deny: denySet.size ? [...denySet].map(JSON.parse) : undefined,
}
}
ensureFieldsWrapped(objectType) {
const stateKey = `_${this.name}`
const sentinelKey = `_${this.name}FieldsWrapped`
// Mark the object to avoid re-wrapping:
if (objectType[sentinelKey]) return
objectType[sentinelKey] = true
const fields = objectType.getFields()
Object.keys(fields).forEach(fieldName => {
const field = fields[fieldName]
const { resolve = defaultFieldResolver } = field
field.resolve = (...args) => {
// Get the required role from the first match
// field first, or then the object or finally undefined
const { roles, group } = field[stateKey] || objectType[stateKey] || {}
// if there's no auth just move along...
if (!roles && !group) {
return resolve.apply(this, args)
}
// make sure there is a user
const context = args[2]
const userId = context.userId
if (!userId) throwNotAuthorized()
const userIsInRole = Roles.userIsInRole(userId, roles, group)
const notAuthorized =
(this.name === POLARITY.DENY && userIsInRole) ||
(this.name === POLARITY.ALLOW && !userIsInRole)
if (notAuthorized) {
throwNotAuthorized()
}
return resolve.apply(this, args)
}
})
}
}
/**
* Returns an array of mutations that have at least one argument of the given type
* @param {GraphQLSchema} schema
* @param {String} typeName
*/
function getMutationsWithArgType(schema, typeName) {
return Object.values(schema.getMutationType().getFields()).filter(
mutation => {
return mutation.args.some(arg => {
return arg.type.name === typeName
})
}
)
}
export class AllowDirective extends RolesDirective {}
export class DenyDirective extends RolesDirective {}
export const directives = {
[POLARITY.ALLOW]: AllowDirective,
[POLARITY.DENY]: DenyDirective,
}