Skip to content

Commit 4d87a61

Browse files
author
Tom Kirkpatrick
authored
Merge pull request #11 from fullcube/rework
Rework
2 parents 7b27cee + 2a3f1e0 commit 4d87a61

File tree

5 files changed

+179
-61
lines changed

5 files changed

+179
-61
lines changed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,18 @@ Options:
117117

118118
[String] : The name of the model that should be used to store and check group access roles. *(default: 'GroupAccess')*
119119

120+
- `foreignKey`
121+
122+
[String] : The foreign key that should be used to determine which access group a model belongs to. *(default: 'groupId')*
123+
120124
- `groupRoles`
121125

122126
[Array] : A list of group names. *(default: [ '$group:admin', '$group:member' ])*
123127

124-
- `foreignKey`
128+
- `applyToStatic`
129+
130+
[Boolean] : Set to *true* to apply ACLs to static methods (by means of query filtering). *(default: false)*
125131

126-
[String] : The foreign key that should be used to determine which access group a model belongs to. *(default: 'groupId')*
127132

128133
## Tests
129134

lib/index.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ module.exports = function loopbackComponentAccess(app, options) {
2626

2727
// Set up role resolvers.
2828
accessUtils.setupRoleResolvers();
29-
// Set up model opertion hooks
30-
accessUtils.setupModels();
29+
30+
// Set up model opertion hooks.
31+
if (options.applyToStatic) {
32+
accessUtils.setupFilters();
33+
}
34+
3135
// TODO: Create Group Access model automatically if one hasn't been specified
3236
};

lib/middleware/user-context.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,10 @@ module.exports = function userContextMiddleware() {
2323
loopbackContext.set('accessToken', req.accessToken.id);
2424
const app = req.app;
2525
const UserModel = app.accessUtils.options.userModel || 'User';
26-
const GroupAccessModel = app.accessUtils.options.groupAccessModel || 'GroupAccess';
27-
2826

2927
return Promise.join(
3028
app.models[UserModel].findById(req.accessToken.userId),
31-
app.models[GroupAccessModel].find({
32-
where: {
33-
userId: req.accessToken.userId
34-
}
35-
}),
29+
app.accessUtils.getUserGroups(req.accessToken.userId),
3630
(user, groups) => {
3731
if (!user) {
3832
return next(new Error('No user with this access token was found.'));

lib/utils.js

Lines changed: 160 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ module.exports = class AccessUtils {
1313
this.options = _defaults({ }, options, {
1414
userModel: 'User',
1515
roleModel: 'Role',
16-
groupModel: 'Group',
1716
groupAccessModel: 'GroupAccess',
17+
groupModel: 'Group',
18+
foreignKey: 'groupId',
1819
groupRoles: [
1920
'$group:admin',
2021
'$group:member'
21-
]
22+
],
23+
applyToStatic: false
2224
});
2325
// Default the foreignKey to the group model name + Id.
2426
this.options.foreignKey = this.options.foreignKey || `${this.options.groupModel.toLowerCase()}Id`;
@@ -47,8 +49,10 @@ module.exports = class AccessUtils {
4749
/**
4850
* Add operation hooks to limit access.
4951
*/
50-
setupModels() {
51-
this.getGroupContentModels().forEach(modelName => {
52+
setupFilters() {
53+
const models = [ this.options.groupModel ].concat(this.getGroupContentModels());
54+
55+
models.forEach(modelName => {
5256
const Model = this.app.models[modelName];
5357

5458
if (typeof Model.observe === 'function') {
@@ -81,15 +85,15 @@ module.exports = class AccessUtils {
8185
debug('%s observe access: query=%s, options=%o, hookState=%o',
8286
Model.modelName, JSON.stringify(ctx.query, null, 4), ctx.options, ctx.hookState);
8387

84-
return this.buildFilter(currentUser.getId())
88+
return this.buildFilter(currentUser.getId(), ctx.Model)
8589
.then(filter => {
86-
debug('filter: %o', filter);
90+
debug('original query: %o', JSON.stringify(ctx.query, null, 4));
8791
const where = ctx.query.where ? {
8892
and: [ ctx.query.where, filter ]
8993
} : filter;
9094

9195
ctx.query.where = where;
92-
debug('where query modified to: %s', JSON.stringify(ctx.query, null, 4));
96+
debug('modified query: %s', JSON.stringify(ctx.query, null, 4));
9397
});
9498
}
9599
return next();
@@ -101,16 +105,19 @@ module.exports = class AccessUtils {
101105
/**
102106
* Build a where filter to restrict search results to a users group
103107
*
104-
* @param {String} userId UserId to build filter for,
108+
* @param {String} userId UserId to build filter for.
109+
* @param {Object} Model Model to build filter for,
105110
* @returns {Object} A where filter.
106111
*/
107-
buildFilter(userId) {
112+
buildFilter(userId, Model) {
108113
const filter = { };
114+
const key = this.isGroupModel(Model) ? Model.getIdName() : this.options.foreignKey;
115+
// TODO: Support key determination based on the belongsTo relationship.
109116

110117
return this.getUserGroups(userId)
111118
.then(userGroups => {
112119
userGroups = Array.from(userGroups, group => group[this.options.foreignKey]);
113-
filter[this.options.foreignKey] = { inq: userGroups };
120+
filter[key] = { inq: userGroups };
114121
return filter;
115122
});
116123
}
@@ -132,6 +139,23 @@ module.exports = class AccessUtils {
132139
return false;
133140
}
134141

142+
/**
143+
* Check if a model class is the configured group access model.
144+
*
145+
* @param {String|Object} modelClass Model class to check.
146+
* @returns {Boolean} Returns true if the principalId is on the expected format.
147+
*/
148+
isGroupAccessModel(modelClass) {
149+
if (modelClass) {
150+
const groupAccessModel = this.app.models[this.options.groupAccessModel];
151+
152+
return modelClass === groupAccessModel ||
153+
modelClass.prototype instanceof groupAccessModel ||
154+
modelClass === this.options.groupAccessModel;
155+
}
156+
return false;
157+
}
158+
135159
/**
136160
* Get a list of group content models (models that have a belongs to relationship to the group model)
137161
*
@@ -143,8 +167,8 @@ module.exports = class AccessUtils {
143167
Object.keys(this.app.models).forEach(modelName => {
144168
const modelClass = this.app.models[modelName];
145169

146-
// TODO: Should we allow the access group model to be treated as a group content model too?
147-
if (modelName === this.options.groupAccessModel) {
170+
// Mark the group itself as a group or the group access model.
171+
if (this.isGroupModel(modelClass) || this.isGroupAccessModel(modelClass)) {
148172
return;
149173
}
150174

@@ -207,14 +231,6 @@ module.exports = class AccessUtils {
207231
const ctx = this.app.loopback.getCurrentContext();
208232
const currentUser = ctx && ctx.get('currentUser') || null;
209233

210-
if (ctx) {
211-
debug('getCurrentUser() - currentUser: %o', currentUser);
212-
}
213-
else {
214-
// this means its a server-side logic call w/o any HTTP req/resp aspect to it.
215-
debug('getCurrentUser() - no loopback context');
216-
}
217-
218234
return currentUser;
219235
}
220236

@@ -227,14 +243,6 @@ module.exports = class AccessUtils {
227243
const ctx = this.app.loopback.getCurrentContext();
228244
const currentUserGroups = ctx && ctx.get('currentUserGroups') || [];
229245

230-
if (ctx) {
231-
debug('currentUserGroups(): %o', currentUserGroups);
232-
}
233-
else {
234-
// this means its a server-side logic call w/o any HTTP req/resp aspect to it.
235-
debug('currentUserGroups(): no loopback context');
236-
}
237-
238246
return currentUserGroups;
239247
}
240248

@@ -268,21 +276,51 @@ module.exports = class AccessUtils {
268276
const Role = this.app.models[this.options.roleModel];
269277

270278
Role.registerResolver(accessGroup, (role, context, cb) => {
271-
const currentUser = this.getCurrentUser();
279+
cb = cb || createPromiseCallback();
280+
const modelClass = context.model;
281+
const modelId = context.modelId;
282+
const userId = context.getUserId();
272283
const roleName = this.extractRoleName(role);
273284
const GroupAccess = this.app.models[this.options.groupAccessModel];
274285
const scope = { };
275286

276-
// Do not allow anonymous users.
277-
if (!currentUser) {
278-
debug('access denied for anonymous user');
279-
return process.nextTick(() => cb(null, false));
287+
debug(`Role resolver for ${role}: evaluate ${modelClass.modelName} with id: ${modelId} for user: ${userId}`);
288+
289+
// No userId is present
290+
if (!userId) {
291+
process.nextTick(() => {
292+
debug('Deny access for anonymous user');
293+
cb(null, false);
294+
});
295+
return cb.promise;
280296
}
281297

282-
debug(`Role resolver for ${role}: evaluate ${context.model.definition.name} with id: ${context.modelId}` +
283-
` for currentUser.getId(): ${currentUser.getId()}`);
298+
this.app.loopback.getCurrentContext().set('groupAccessApplied', true);
299+
300+
/**
301+
* Basic application that does not cover static methods. Similar to $owner. (RECOMMENDED)
302+
*/
303+
if (!this.options.applyToStatic) {
304+
if (!context || !modelClass || !modelId) {
305+
process.nextTick(() => {
306+
debug('Deny access (context: %s, context.model: %s, context.modelId: %s)',
307+
Boolean(context), Boolean(modelClass), Boolean(modelId));
308+
cb(null, false);
309+
});
310+
return cb.promise;
311+
}
312+
313+
this.isGroupMemberWithRole(modelClass, modelId, userId, roleName)
314+
.then(res => cb(null, res))
315+
.catch(cb);
284316

285-
return Promise.join(this.getCurrentGroupId(context), this.getTargetGroupId(context),
317+
return cb.promise;
318+
}
319+
320+
/**
321+
* More complex application that also covers static methods. (EXPERIMENTAL)
322+
*/
323+
Promise.join(this.getCurrentGroupId(context), this.getTargetGroupId(context),
286324
(currentGroupId, targetGroupId) => {
287325
if (!currentGroupId) {
288326
// TODO: Use promise cancellation to abort the chain early.
@@ -293,10 +331,7 @@ module.exports = class AccessUtils {
293331
scope.currentGroupId = currentGroupId;
294332
scope.targetGroupId = targetGroupId;
295333
const actions = [ ];
296-
const conditions = {
297-
userId: currentUser.getId(),
298-
role: roleName
299-
};
334+
const conditions = { userId, role: roleName };
300335

301336
conditions[this.options.foreignKey] = currentGroupId;
302337
actions.push(GroupAccess.count(conditions));
@@ -320,15 +355,13 @@ module.exports = class AccessUtils {
320355
// Determine grant based on the current/target group context.
321356
res = currentGroupCount > 0;
322357

323-
debug(`user ${currentUser.getId()} ${res ? 'is a' : 'is not a'}` +
324-
`${roleName} of group ${scope.currentGroupId}`);
358+
debug(`user ${userId} ${res ? 'is a' : 'is not a'} ${roleName} of group ${scope.currentGroupId}`);
325359

326360
// If it's an attempt to save into a new group, also ensure the user has access to the target group.
327361
if (scope.targetGroupId && scope.targetGroupId !== scope.currentGroupId) {
328362
const tMember = targetGroupCount > 0;
329363

330-
debug(`user ${currentUser.getId()} ${tMember ? 'is a' : 'is not a'}` +
331-
`${roleName} of group ${scope.targetGroupId}`);
364+
debug(`user ${userId} ${tMember ? 'is a' : 'is not a'} ${roleName} of group ${scope.targetGroupId}`);
332365
res = res && tMember;
333366
}
334367
}
@@ -341,7 +374,88 @@ module.exports = class AccessUtils {
341374
return cb(null, res);
342375
})
343376
.catch(cb);
377+
return cb.promise;
378+
});
379+
}
380+
381+
/**
382+
* Check if a given user ID has a given role in the model instances group.
383+
* @param {Function} modelClass The model class
384+
* @param {*} modelId The model ID
385+
* @param {*} userId The user ID
386+
* @param {*} roleId The role ID
387+
* @param {Function} callback Callback function
388+
*/
389+
isGroupMemberWithRole(modelClass, modelId, userId, roleId, cb) {
390+
cb = cb || createPromiseCallback();
391+
debug('isGroupMemberWithRole: modelClass: %o, modelId: %o, userId: %o, roleId: %o',
392+
modelClass && modelClass.modelName, modelId, userId, roleId);
393+
394+
// No userId is present
395+
if (!userId) {
396+
process.nextTick(() => {
397+
cb(null, false);
398+
});
399+
return cb.promise;
400+
}
401+
402+
// Is the modelClass GroupModel or a subclass of GroupModel?
403+
if (this.isGroupModel(modelClass)) {
404+
debug('Access to Group Model %s attempted', modelId);
405+
this.hasRoleInGroup(userId, roleId, modelId)
406+
.then(res => cb(null, res));
407+
return cb.promise;
408+
}
409+
410+
modelClass.findById(modelId, (err, inst) => {
411+
if (err || !inst) {
412+
debug('Model not found for id %j', modelId);
413+
return cb(err, false);
414+
}
415+
debug('Model found: %j', inst);
416+
const groupId = inst[this.options.foreignKey];
417+
418+
// Ensure groupId exists and is not a function/relation
419+
if (groupId && typeof groupId !== 'function') {
420+
return this.hasRoleInGroup(userId, roleId, groupId)
421+
.then(res => cb(null, res));
422+
}
423+
// Try to follow belongsTo
424+
for (const relName in modelClass.relations) {
425+
const rel = modelClass.relations[relName];
426+
427+
if (rel.type === 'belongsTo' && this.isGroupModel(rel.modelTo)) {
428+
debug('Checking relation %s to %s: %j', relName, rel.modelTo.modelName, rel);
429+
return inst[relName](function processRelatedGroup(error, group) {
430+
if (!error && group) {
431+
debug('Group found: %j', group.getId());
432+
return cb(null, this.hasRoleInGroup(userId, roleId, group.getId()));
433+
}
434+
return cb(error, false);
435+
});
436+
}
437+
}
438+
debug('No matching belongsTo relation found for model %j and group: %j', modelId, groupId);
439+
return cb(null, false);
344440
});
441+
return cb.promise;
442+
}
443+
444+
hasRoleInGroup(userId, role, group, cb) {
445+
debug('hasRoleInGroup: role: %o, group: %o, userId: %o', role, group, userId);
446+
cb = cb || createPromiseCallback();
447+
const GroupAccess = this.app.models[this.options.groupAccessModel];
448+
const conditions = { userId, role };
449+
450+
conditions[this.options.foreignKey] = group;
451+
GroupAccess.count(conditions)
452+
.then(count => {
453+
const res = count > 0;
454+
455+
debug(`User ${userId} ${res ? 'HAS' : 'DOESNT HAVE'} ${role} role in group ${group}`);
456+
cb(null, res);
457+
});
458+
return cb.promise;
345459
}
346460

347461
/**
@@ -362,7 +476,7 @@ module.exports = class AccessUtils {
362476
return cb.promise;
363477
}
364478

365-
// If we are accessing an existing model, get the store id from the existing model instance.
479+
// If we are accessing an existing model, get the group id from the existing model instance.
366480
// TODO: Cache this result so that it can be reused across each ACL lookup attempt.
367481
if (context.modelId) {
368482
debug(`fetching group id for existing model with id: ${context.modelId}`);
@@ -372,7 +486,7 @@ module.exports = class AccessUtils {
372486
.then(item => {
373487
// TODO: Attempt to follow relationships in addition to the foreign key.
374488
if (item) {
375-
debug(`determined group id ${item[this.options.foreignKey]} from existing model %o`, item);
489+
debug(`determined group id ${item[this.options.foreignKey]} from existing model ${context.modelId}`);
376490
groupId = item[this.options.foreignKey];
377491
}
378492
cb(null, groupId);

0 commit comments

Comments
 (0)