Skip to content

Commit fefa388

Browse files
committed
Lots of improvements...
- Adding a bunch more tests. I feel like the coverage is there and this probably mostly works now - Adding a `permissions` option to find queries. If that's set to try, permissions will be injected into the returned object. - Readme is sorta decent, but not really.
1 parent d27af52 commit fefa388

File tree

11 files changed

+3853
-2241
lines changed

11 files changed

+3853
-2241
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "airbnb-base",
33
"rules": {
4-
"no-underscore-dangle": ["error", { "allow": ["_conditions", "_fields", "_update", "_doc"] }],
4+
"no-underscore-dangle": ["error", { "allow": ["_conditions", "_fields", "_update", "_doc", "_id"] }],
55
"no-param-reassign": ["error", { "props": false }]
66
}
77
}

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,31 @@ Car.find({}, null, { authPayload: { companyName: 'Toyota' } }).exec(...);
136136
myCar.save({ authPayload: { companyName: 'Honda' } });
137137
```
138138
139+
You can also have the permissions for a specific document injected into the document when returned from a find query using the `permissions` option on the query. The permissions will be inserted into the object using the key `permissions` unless you specify the desired key name as the permissions option.
140+
141+
```javascript
142+
const user = await User.find().setOptions({ authLevel: 'admin': permissions: true }).exec();
143+
144+
console.log(user.permissions);
145+
// Outputs:
146+
// {
147+
// read: [...],
148+
// write: [...],
149+
// remove: [boolean]
150+
// }
151+
152+
// OR
153+
const user = await User.find().setOptions({ authLevel: 'admin': permissions: 'foo' }).exec();
154+
155+
console.log(user.foo);
156+
// Outputs:
157+
// {
158+
// read: [...],
159+
// write: [...],
160+
// remove: [boolean]
161+
// }
162+
```
163+
139164
#### Example Uses
140165
141166
###### NOTE: If no authLevel is able to be determined, permission to perform the action will be denied. If you would like to circumvent authorization, pass `false` as the authLevel (e.g. `myModel.find().setAuhtLevel(false).exec();`, which will disable authorization for that specific query).

index.js

Lines changed: 33 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ const _ = require('lodash');
22
const {
33
getAuthorizedFields,
44
hasPermission,
5+
authIsDisabled,
6+
sanitizeDocumentList,
7+
getUpdatePaths,
58
} = require('./lib/helpers');
69

710
const PermissionDeniedError = require('./lib/PermissionDeniedError');
811

9-
// TODO implement a pluginOption for putting the permissions into the results object for
10-
// find queries
1112
module.exports = (schema) => {
12-
let authorizationEnabled = true;
13-
1413
function save(doc, options, next) {
1514
if (doc.isNew && !hasPermission(schema, options, 'create', doc)) {
1615
return next(new PermissionDeniedError('create'));
@@ -45,44 +44,11 @@ module.exports = (schema) => {
4544

4645
function find(query, docs, next) {
4746
const docList = _.castArray(docs);
48-
const multi = docList.length;
49-
50-
const processedResult = _.map(docList, (doc) => {
51-
if (!doc) { return doc; }
52-
53-
const authorizedFields = getAuthorizedFields(schema, query.options, 'read', doc);
54-
55-
if (getAuthorizedFields.length === 0) { return; }
56-
57-
// Check to see if group has the permission to see the fields that came back. Fields
58-
// that don't will be removed.
59-
const authorizedFieldsSet = new Set(authorizedFields);
60-
const innerDoc = doc._doc || doc;
61-
for (const pathName of _.keys(innerDoc)) {
62-
if (!authorizedFieldsSet.has(pathName)) {
63-
delete innerDoc[pathName];
64-
}
65-
}
47+
const multi = _.isArrayLike(docList);
6648

67-
// Special work. Wipe out the getter for the virtuals that have been set on the
68-
// schema that are not authorized to come back
69-
for (const pathName of _.keys(schema.virtuals)) {
70-
if (!authorizedFieldsSet.has(pathName)) {
71-
// These virtuals are set with `Object.defineProperty`. You cannot overwrite them
72-
// by directly setting the value to undefined, or by deleting the key in the
73-
// document. This is potentially slow with lots of virtuals
74-
Object.defineProperty(doc, pathName, {
75-
value: undefined
76-
});
77-
}
78-
}
79-
80-
return _.isEmpty(innerDoc) ? undefined : doc;
81-
});
49+
const sanitizedResult = sanitizeDocumentList(schema, query.options, docs);
8250

83-
const filteredResult = _.filter(processedResult);
84-
85-
return next(null, multi ? filteredResult : filteredResult[0]);
51+
return next(null, multi ? sanitizedResult : sanitizedResult[0]);
8652
}
8753

8854
function update(query, next) {
@@ -97,18 +63,15 @@ module.exports = (schema) => {
9763

9864
const authorizedFields = getAuthorizedFields(schema, query.options, 'write');
9965

100-
// create an update object that has been sanitized based on permissions
101-
const sanitizedUpdate = {};
102-
authorizedFields.forEach((field) => {
103-
sanitizedUpdate[field] = query._update[field];
104-
});
105-
10666
// check to see if the group is trying to update a field it does not have permission to
107-
const discrepancies = _.difference(Object.keys(query._update), Object.keys(sanitizedUpdate));
67+
const modifiedPaths = getUpdatePaths(query._update);
68+
const discrepancies = _.difference(modifiedPaths, authorizedFields);
10869
if (discrepancies.length > 0) {
10970
return next(new PermissionDeniedError('write', discrepancies));
11071
}
111-
query._update = sanitizedUpdate;
72+
73+
// TODO handle the overwrite option
74+
// TODO handle Model.updateMany
11275

11376
// TODO, see if this section works at all. Seems off that the `_fields` property is the
11477
// thing that determines what fields come back
@@ -118,44 +81,54 @@ module.exports = (schema) => {
11881
// create a sanitizedReturnFields object that will be used to return only the fields that a
11982
// group has access to read
12083
const sanitizedReturnFields = {};
121-
for (const field of authorizedReturnFields) {
84+
authorizedReturnFields.forEach((field) => {
12285
if (!query._fields || query._fields[field]) {
12386
sanitizedReturnFields[field] = 1;
12487
}
125-
}
88+
});
12689
query._fields = sanitizedReturnFields;
12790

12891
return next();
12992
}
13093

94+
// Find paths with permissioned schemas and store those so deep checks can be done
95+
// on the right paths at call time.
96+
schema.pathsWithPermissionedSchemas = {};
97+
schema.eachPath((path, schemaType) => {
98+
const subSchema = schemaType.schema;
99+
if (subSchema && subSchema.permissions) {
100+
schema.pathsWithPermissionedSchemas[path] = subSchema;
101+
}
102+
});
103+
131104
schema.pre('findOneAndRemove', function preFindOneAndRemove(next) {
132-
if (!authorizationEnabled) { return next(); }
105+
if (authIsDisabled(this.options)) { return next(); }
133106
return removeQuery(this, next);
134107
});
135108
// TODO, WTF, how to prevent someone from Model.find().remove().exec(); That doesn't
136109
// fire any remove hooks. Does it fire a find hook?
137110
schema.pre('remove', function preRemove(next, options) {
138-
if (!authorizationEnabled) { return next(); }
111+
if (authIsDisabled(options)) { return next(); }
139112
return removeDoc(this, options, next);
140113
});
141114
schema.pre('save', function preSave(next, options) {
142-
if (!authorizationEnabled) { return next(); }
115+
if (authIsDisabled(options)) { return next(); }
143116
return save(this, options, next);
144117
});
145118
schema.post('find', function postFind(doc, next) {
146-
if (!authorizationEnabled) { return next(); }
119+
if (authIsDisabled(this.options)) { return next(); }
147120
return find(this, doc, next);
148121
});
149122
schema.post('findOne', function postFindOne(doc, next) {
150-
if (!authorizationEnabled) { return next(); }
123+
if (authIsDisabled(this.options)) { return next(); }
151124
return find(this, doc, next);
152125
});
153126
schema.pre('update', function preUpdate(next) {
154-
if (!authorizationEnabled) { return next(); }
127+
if (authIsDisabled(this.options)) { return next(); }
155128
return update(this, next);
156129
});
157130
schema.pre('findOneAndUpdate', function preFindOneAndUpdate(next) {
158-
if (!authorizationEnabled) { return next(); }
131+
if (authIsDisabled(this.options)) { return next(); }
159132
return update(this, next);
160133
});
161134

@@ -164,11 +137,7 @@ module.exports = (schema) => {
164137
return this;
165138
};
166139

167-
schema.static('disableAuthorization', () => {
168-
authorizationEnabled = false;
169-
});
170-
171-
schema.static('enableAuthorization', () => {
172-
authorizationEnabled = true;
173-
});
140+
schema.statics.canCreate = function canCreate(options) {
141+
return hasPermission(this.schema, options, 'create');
142+
};
174143
};

lib/helpers.js

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ function getAuthorizedFields(schema, options, action, doc) {
2727
const authLevels = resolveAuthLevel(schema, options, doc);
2828

2929
return _.chain(authLevels)
30-
.map(level => schema.permissions[level][action])
31-
.flatten()
32-
.filter(path => schema.path(path) || schema.virtualpath(path)) // make sure the fields are in the schema
30+
.flatMap(level => schema.permissions[level][action])
31+
.filter(path => schema.path(path) || schema.virtualpath(path)) // ensure fields are in schema
3332
.uniq() // dropping duplicates
3433
.value();
3534
}
@@ -42,8 +41,104 @@ function hasPermission(schema, options, action, doc) {
4241
return _.some(authLevels, level => perms[level][action]);
4342
}
4443

44+
function authIsDisabled(options) {
45+
return options && options.authLevel === false;
46+
}
47+
48+
function embedPermissions(schema, options, doc) {
49+
if (!options || !options.permissions) { return; }
50+
51+
const permsKey = options.permissions === true ? 'permissions' : options.permissions;
52+
doc[permsKey] = {
53+
read: getAuthorizedFields(schema, options, 'read', doc),
54+
write: getAuthorizedFields(schema, options, 'write', doc),
55+
remove: hasPermission(schema, options, 'remove', doc),
56+
};
57+
}
58+
59+
function sanitizeDocument(schema, options, doc) {
60+
const authorizedFields = getAuthorizedFields(schema, options, 'read', doc);
61+
62+
if (!doc || getAuthorizedFields.length === 0) { return false; }
63+
64+
// Check to see if group has the permission to see the fields that came back. Fields
65+
// that don't will be removed.
66+
const authorizedFieldsSet = new Set(authorizedFields);
67+
const innerDoc = doc._doc || doc;
68+
Object.keys(innerDoc).forEach((pathName) => {
69+
if (!authorizedFieldsSet.has(pathName)) {
70+
delete innerDoc[pathName];
71+
}
72+
});
73+
74+
// Special work. Wipe out the getter for the virtuals that have been set on the
75+
// schema that are not authorized to come back
76+
Object.keys(schema.virtuals).forEach((pathName) => {
77+
if (!authorizedFieldsSet.has(pathName)) {
78+
// These virtuals are set with `Object.defineProperty`. You cannot overwrite them
79+
// by directly setting the value to undefined, or by deleting the key in the
80+
// document. This is potentially slow with lots of virtuals
81+
Object.defineProperty(doc, pathName, {
82+
value: undefined,
83+
});
84+
}
85+
});
86+
87+
if (_.isEmpty(innerDoc)) { return false; }
88+
89+
// Check to see if we're going to be inserting the permissions info
90+
if (options.permissions) {
91+
embedPermissions(schema, options, doc);
92+
}
93+
94+
// Apply the rules down one level if there are any path specific permissions
95+
_.each(schema.pathsWithPermissionedSchemas, (path, subSchema) => {
96+
if (innerDoc[path]) {
97+
// eslint-disable-next-line no-use-before-define
98+
innerDoc[path] = sanitizeDocumentList(subSchema, options, innerDoc[path]);
99+
}
100+
});
101+
102+
return doc;
103+
}
104+
105+
function sanitizeDocumentList(schema, options, docs) {
106+
const multi = _.isArrayLike(docs);
107+
const docList = _.castArray(docs);
108+
109+
const filteredResult = _.chain(docList)
110+
.map((doc) => {
111+
const upgradedOptions = _.isEmpty(schema.pathsWithPermissionedSchemas)
112+
? options
113+
: _.merge({}, options, { authPayload: { originalDoc: doc } });
114+
115+
return sanitizeDocument(schema, upgradedOptions, doc);
116+
})
117+
.filter(docList)
118+
.value();
119+
120+
return multi ? filteredResult : filteredResult[0];
121+
}
122+
123+
function getUpdatePaths(updates) {
124+
// query._update is sometimes in the form of `{ $set: { foo: 1 } }`, where the top level
125+
// is atomic operations. See: http://mongoosejs.com/docs/api.html#query_Query-update
126+
// For findOneAndUpdate, the top level may be the fields that we want to examine.
127+
return _.flatMap(updates, (val, key) => {
128+
if (_.startsWith(key, '$')) {
129+
return Object.keys(val);
130+
}
131+
132+
return key;
133+
});
134+
}
135+
45136
module.exports = {
46137
resolveAuthLevel,
47138
getAuthorizedFields,
48139
hasPermission,
140+
authIsDisabled,
141+
embedPermissions,
142+
sanitizeDocumentList,
143+
getUpdatePaths,
49144
};

0 commit comments

Comments
 (0)