-
Notifications
You must be signed in to change notification settings - Fork 29
/
access.js
435 lines (365 loc) · 11.1 KB
/
access.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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
const ACCESS_TYPES = require('./matrix').ACCESS_TYPES
const clientModel = require('./client')
const roleModel = require('./role')
const Access = function () {
clientModel.setWriteCallback(
this.write.bind(this)
)
roleModel.setWriteCallback(
this.write.bind(this)
)
}
/**
* Combines values from two matrices to produce a matrix with the
* broadest permissions possible.
*
* @param {Object} matrix1
* @param {Object} matrix2
* @return {Objct}
*/
Access.prototype.combineAccessMatrices = function (matrix1 = {}, matrix2 = {}) {
let accessTypes = [...new Set(
Object.keys(matrix1).concat(Object.keys(matrix2))
)]
accessTypes.forEach(accessType => {
// If the existing value for the access type is already `true`
// or if the new candidate value is `false`, the existing value
// will remain unchanged.
if (
matrix1[accessType] === true ||
!matrix2[accessType]
) {
return
}
// If the candidate value is `true`, there's nothing else to do
// other than setting the existing value to `true`.
if (matrix2[accessType] === true) {
matrix1[accessType] = true
return
}
// If the existing value is `false`, we take whatever the candidate
// value is.
if (!matrix1[accessType]) {
matrix1[accessType] = matrix2[accessType]
return
}
// At this point, we now that both the existing and the candidate
// values are objects. We can start by merging the `fields` objects
// of both the existing and candidate values, so that they result in
// the broadest set of fields.
if (matrix1[accessType].fields || matrix2[accessType].fields) {
let fields = this.mergeFields([
matrix1[accessType].fields,
matrix2[accessType].fields
])
// If `fields` is the only property in the access type and the
// result of merging the matrices resulted in a projection with
// no field restrictions, we can simply set the access type to
// `true`.
if (
Object.keys(matrix1[accessType]).length === 1 &&
Object.keys(fields).length === 0
) {
matrix1[accessType] = true
} else {
matrix1[accessType].fields = fields
}
}
// We can't do the same with `filter`, because it's not possible to
// compute the union of two filter expressions (we'd have to use an
// *or* expression, which API doesn't currently have).
})
return matrix1
}
/**
* Takes an ACL access value and an input, which should be an array of fields
* or an object where keys represent fields. This method will return the input
* with any fields excluded by the ACL access value filtered out.
*
* @param {Object} access
* @param {Array/Object} input
* @return {Array/Object}
*/
Access.prototype.filterFields = function (access, input) {
let fields = access.fields
if ((typeof fields !== 'object') || !input || !Object.keys(input).length) {
return input
}
let isExclusion = Object.keys(fields).some(field => {
return field !== '_id' && fields[field] === 0
})
let allowedFields = Array.isArray(input)
? input
: Object.keys(input)
allowedFields = allowedFields.filter(field => {
return (
(isExclusion && (fields[field] === undefined)) ||
!isExclusion && (fields[field] === 1)
)
})
if (Array.isArray(input)) {
return allowedFields
}
return allowedFields.reduce((result, field) => {
result[field] = input[field]
return result
}, {})
}
Access.prototype.get = function ({clientId = null, accessType = null} = {}, resource, {
resolveOwnTypes = true
} = {}) {
if (typeof clientId !== 'string') {
return Promise.resolve({})
}
if (accessType === 'admin') {
let matrix = {}
ACCESS_TYPES.forEach(accessType => {
matrix[accessType] = true
})
return Promise.resolve(matrix)
}
let query = {
client: clientId
}
if (resource) {
query.resource = resource
}
return this.model.get({
query
}).then(({results}) => {
if (!resource) {
let accessByResource = results.reduce((output, result) => {
output[result.resource] = result.access
return output
}, {})
return accessByResource
}
if (
results.length > 0 &&
typeof results[0].access === 'object'
) {
if (resolveOwnTypes) {
return this.resolveOwnTypes(results[0].access, clientId)
}
return results[0].access
}
return {}
})
}
/**
* Resolves role inheritance by taking an array of roles and returning
* another array with those roles plus any roles inherited from the
* initial set. It uses a hash map for caching roles, so that details
* for a given role are only retrieved from the database once.
*
* @param {Array} roles Initial roles
* @param {Object} cache Hash for caching roles
* @param {Array} chain Array with roles found
* @return {Array} full list of roles
*/
Access.prototype.getRoleChain = function (roles = [], cache = {}, chain) {
chain = chain || roles
// We only need to fetch from the database the roles that
// are not already in cache.
let rolesToFetch = roles.filter(role => {
return !Object.keys(cache).includes(role)
})
if (rolesToFetch.length === 0) {
return Promise.resolve(
[...new Set(chain)]
)
}
return roleModel.get(rolesToFetch).then(({results}) => {
let parentRoles = new Set()
results.forEach(role => {
cache[role.name] = role.resources || {}
if (role.extends) {
parentRoles.add(role.extends)
}
})
return this.getRoleChain(
Array.from(parentRoles),
cache,
chain.concat(Array.from(parentRoles))
)
})
}
Access.prototype.getClientRoles = function (clientId) {
return clientModel.get(clientId).then(({results}) => {
if (results.length === 0) {
return []
}
let roles = results[0].roles
if (roles.length === 0) {
return []
}
return this.getRoleChain(roles)
})
}
Access.prototype.mergeFields = function mergeFields (projections) {
let fields = []
let isExclusion = false
projections.some(projection => {
if (!projection) {
fields = []
return true
}
let projectionFields = Object.keys(projection)
let projectionIsExclusion = projectionFields.find(field => {
return field !== '_id' && projection[field] === 0
})
if (projectionIsExclusion) {
if (isExclusion) {
fields = fields.filter(field => {
return projectionFields.includes(field)
})
} else {
fields = projectionFields.filter(field => {
return !fields.includes(field)
})
}
isExclusion = true
} else {
if (isExclusion) {
fields = fields.filter(field => {
return !projectionFields.includes(field)
})
} else {
projectionFields.forEach(field => {
if (!fields.includes(field)) {
fields.push(field)
}
})
}
}
})
return fields.reduce((result, field) => {
result[field] = isExclusion ? 0 : 1
return result
}, {})
}
/**
* Takes the matrix given and resolves any *Own values to
* a filter on the corresponding base type – e.g. having
* {"update": false, updateOwn": true} is equivalent to
* having {"update": {"filter": {"_createdBy": "C1"}}},
* where "C1" is the client ID being resolved to.
*
* @param {Object} matrix
* @param {String} clientId
* @return {Object}
*/
Access.prototype.resolveOwnTypes = function (matrix, clientId) {
let newMatrix = {}
let splitTypes = Object.keys(matrix).reduce((result, accessType) => {
let match = accessType.match(/^(.*)Own$/)
if (match) {
result.own.push(match[1])
} else {
result.base.push(accessType)
}
return result
}, {
base: [],
own: []
})
splitTypes.base.forEach(accessType => {
newMatrix[accessType] = matrix[accessType]
})
splitTypes.own.forEach(baseType => {
let accessType = `${baseType}Own`
if (!matrix[accessType] || (matrix[baseType] === true)) {
return
}
let filter = Object.assign(
{},
newMatrix[baseType] && newMatrix[baseType].filter,
newMatrix[accessType] && newMatrix[accessType].filter,
{_createdBy: clientId}
)
let fields = (matrix[baseType] && matrix[baseType].fields) ||
(matrix[accessType] && matrix[accessType].fields)
newMatrix[baseType] = Object.assign(
{},
newMatrix[baseType],
{filter},
fields ? {fields} : {}
)
})
return newMatrix
}
Access.prototype.setModel = function (model) {
this.model = model
}
/**
* Computes final access matrices for each client and each resource,
* taking into account client-level and role-level permissions. An
* entry is created in the access collection for each client/resource
* pair.
*
* The collection is wiped every time this method is called.
*
* @return {Promise}
*/
Access.prototype.write = function () {
// Keeping a local cache of roles for the course of
// this operation. This way, if X roles inherit from role
// R1, we just fetch R1 from the database once, instead of
// X times.
let roleCache = {}
// Getting all the clients.
return clientModel.get().then(({results}) => {
// An entry is an object with {client, resource, access}. This
// array will serve as a buffer, where we'll store all the
// entries we need to push and then make a single call to
// the database, as opposed to writing every time we process
// a client or a resource.
let entries = []
let queue = Promise.resolve()
// For each client, we find out all the resources they have access to.
results.forEach(client => {
queue = queue.then(() => {
// Getting an array with the name of every role the client is
// assigned to, including inheritance.
return this.getRoleChain(client.roles, roleCache).then(chain => {
// Start with the resources assigned to the client directly.
let clientResources = client.resources || {}
// Take the resources associated with each role and extend
// the corresponding entry in `clientResources`.
chain.forEach(roleName => {
let role = roleCache[roleName]
if (!role) return
Object.keys(role).forEach(resource => {
clientResources[resource] = this.combineAccessMatrices(
clientResources[resource],
role[resource]
)
})
})
Object.keys(clientResources).forEach(resource => {
entries.push({
client: client.clientId,
resource,
access: clientResources[resource]
})
})
})
})
})
return queue.then(() => entries)
}).then(entries => {
// Before we write anything to the access collection, we need
// to delete all existing records.
return this.model.delete({
query: {}
}).then(() => {
if (entries.length === 0) return
return this.model.create({
documents: entries,
rawOutput: true,
validate: false
})
})
})
}
module.exports = new Access()