UNPKG

9.85 kBJavaScriptView Raw
1const httpError = require('http-errors')
2const isEmpty = obj => !Object.keys(obj || {}).length
3const pick = require('lodash.pick')
4const omit = require('lodash.omit')
5
6module.exports = (acl, library = 'role-acl', opts) => {
7 if (!acl) throw new Error('acl is a required parameter!')
8 if (typeof library === 'object') {
9 throw new Error(
10 'objection-authorize@3 now has the signature (acl, library, opts)'
11 )
12 }
13
14 const defaultOpts = {
15 defaultRole: 'anonymous',
16 unauthenticatedErrorCode: 401,
17 unauthorizedErrorCode: 403,
18 userFromResult: false,
19 // below are role-acl specific options
20 contextKey: 'req',
21 roleFromUser: user => user.role,
22 resourceAugments: { true: true, false: false, undefined: undefined }
23 }
24 opts = Object.assign(defaultOpts, opts)
25
26 const lib = require(`./lib/${library}`)
27
28 return Model => {
29 class AuthQueryBuilder extends Model.QueryBuilder {
30 get _shouldCheckAccess() {
31 return this.context()._authorize
32 }
33
34 get _resource() {
35 return this.context()._resource
36 }
37
38 // Wrap the resource to give it all the custom methods & properties
39 // defined in the associating model class (e.g. Post, User).
40 set _resource(_resource) {
41 // Wrap the resource only if it's not an instance of a model already.
42 // Rather than checking if the resource is instance of the Model base class,
43 // we are simply checking that the resource has a $query property.
44 if (!_resource || !_resource.$query)
45 _resource = this.modelClass().fromJson(_resource, {
46 skipValidation: true
47 })
48 this.mergeContext({ _resource })
49 }
50
51 // wrappers around acl, querybuilder, and model
52 _checkAccess(action, body) {
53 if (!this._shouldCheckAccess) return body
54
55 const {
56 _user: user,
57 _resource: resource,
58 _opts: opts,
59 _action
60 } = this.context()
61 // allowed the specified action to override the default, inferred action
62 action = _action || action
63
64 const access = lib.getAccess(acl, user, resource, action, body, opts)
65
66 // authorize request
67 if (!lib.isAuthorized(access, action, resource))
68 throw httpError(
69 user.role === opts.defaultRole
70 ? opts.unauthenticatedErrorCode
71 : opts.unauthorizedErrorCode
72 )
73
74 return access
75 }
76
77 // convenience helper for insert/update/delete
78 _filterBody(action, body) {
79 if (!this._shouldCheckAccess) return body
80
81 const access = this._checkAccess(action, body)
82 const resource = this._resource
83
84 // there's no need to cache these fields because this isn't the read access.
85 const pickFields = lib.pickFields(access, action, resource)
86 const omitFields = lib.omitFields(access, action, resource)
87
88 if (pickFields.length) body = pick(body, pickFields)
89 if (omitFields.length) body = omit(body, omitFields)
90
91 return body
92 }
93
94 // insert/patch/update/delete are the "primitive" query actions.
95 // All other methods like insertAndFetch or deleteById are built on these.
96
97 // automatically checks if you can create this resource, and if yes,
98 // restricts the body object to only the fields they're allowed to set.
99 insert(body) {
100 return super.insert(this._filterBody('create', body))
101 }
102
103 insertAndFetch(body) {
104 return super.insertAndFetch(this._filterBody('create', body))
105 }
106
107 patch(body) {
108 return super.patch(this._filterBody('update', body))
109 }
110
111 patchAndFetch(body) {
112 return super.patchAndFetch(this._filterBody('update', body))
113 }
114
115 // istanbul ignore next
116 patchAndFetchById(id, body) {
117 return super.patchAndFetchById(id, this._filterBody('update', body))
118 }
119
120 // istanbul ignore next
121 update(body) {
122 return super.update(this._filterBody('update', body))
123 }
124
125 // istanbul ignore next
126 updateAndFetch(body) {
127 return super.updateAndFetch(this._filterBody('update', body))
128 }
129
130 // istanbul ignore next
131 updateAndFetchById(id, body) {
132 return super.updateAndFetchById(id, this._filterBody('update', body))
133 }
134
135 delete(body) {
136 this._checkAccess('delete', body)
137 return super.delete()
138 }
139
140 // istanbul ignore next
141 deleteById(id, body) {
142 this._checkAccess('delete', body)
143 return super.deleteById(id)
144 }
145
146 // specify a custom action, which takes precedence over the "default" action.
147 action(_action) {
148 this.mergeContext({ _action })
149 return this
150 }
151
152 // result is always an array, so we figure out if we should look at the result
153 // as a single object instead by looking at whether .first() was called or not.
154 first() {
155 this.mergeContext({ _first: true })
156 return super.first()
157 }
158
159 // THE magic method that schedules the actual authorization logic to be called
160 // later down the line when the query is built and is ready to be executed.
161 authorize(user, resource, optOverride) {
162 resource = resource || this.context()._instance || {}
163 this._resource = resource
164 this.mergeContext({
165 _user: Object.assign({ role: opts.defaultRole }, user),
166 _opts: Object.assign({}, opts, optOverride),
167 _authorize: true
168 })
169 // This is run AFTER the query has been completely built.
170 // In other words, the query already checked create/update/delete access
171 // by this point, and the only thing to check now is the read access,
172 // IF the resource is specified.
173 // Otherwise, we check the read access after the query has been run, on the
174 // query results as the resource.
175 .runBefore(async (result, query) => {
176 if (query.isFind() && !isEmpty(resource)) {
177 const readAccess = query._checkAccess('read')
178
179 // store the read access so that it can be reused after the query.
180 query.mergeContext({ readAccess })
181 }
182
183 return result
184 })
185 .runAfter(async (result, query) => {
186 // If there's no result objects, we don't need to filter them.
187 if (typeof result !== 'object' || !query._shouldCheckAccess)
188 return result
189
190 const isArray = Array.isArray(result)
191
192 let {
193 _resource: resource,
194 _first: first,
195 _opts: opts,
196 _user: user,
197 _readAccess: readAccess
198 } = query.context()
199
200 // Set the resource as the result if it's still not set!
201 // Note, since the resource needs to be singular, it can only be done
202 // when there's only one result -
203 // we're trusting that if the query returns an array of results,
204 // then you've already filtered it according to the user's read access
205 // in the query (instead of relying on the ACL to do it) since it's costly
206 // to check every single item in the result array...
207 if (isEmpty(resource) && (!isArray || first)) {
208 resource = isArray ? result[0] : result
209 resource = query.modelClass().fromJson(resource, {
210 skipValidation: true
211 })
212 query.mergeContext({ _resource: resource })
213 }
214
215 // after create/update operations, the returning result may be the requester
216 if (
217 (query.isInsert() || query.isUpdate()) &&
218 !isArray &&
219 opts.userFromResult
220 ) {
221 // check if the user is changed
222 const resultIsUser =
223 typeof opts.userFromResult === 'function'
224 ? opts.userFromResult(user, result)
225 : true
226
227 // now we need to re-check read access from the context of the changed user
228 if (resultIsUser) {
229 // first, override the user and resource context for _checkAccess
230 query.mergeContext({ _user: result })
231 // then obtain read access
232 readAccess = query._checkAccess('read')
233 }
234 }
235
236 readAccess = readAccess || query._checkAccess('read')
237
238 // if we're fetching multiple resources, the result will be an array.
239 // While access.filter() accepts arrays, we need to invoke any $formatJson()
240 // hooks by individually calling toJSON() on individual models since:
241 // 1. arrays don't have toJSON() method,
242 // 2. objection-visibility doesn't work without calling $formatJson()
243 return isArray
244 ? result.map(model => model._filterModel(readAccess))
245 : result._filterModel(readAccess)
246 })
247
248 // for chaining
249 return this
250 }
251 }
252
253 return class extends Model {
254 // filter the model instance directly
255 _filterModel(readAccess) {
256 const pickFields = lib.pickFields(readAccess, 'read', this)
257 const omitFields = lib.omitFields(readAccess, 'read', this)
258
259 if (pickFields.length) this.$pick(pickFields)
260 if (omitFields.length) this.$omit(omitFields)
261
262 return this // for chaining
263 }
264
265 // inject instance context
266 $query(trx) {
267 return super.$query(trx).mergeContext({ _instance: this })
268 }
269
270 $relatedQuery(relation, trx) {
271 return super
272 .$relatedQuery(relation, trx)
273 .mergeContext({ _instance: this })
274 }
275
276 static get QueryBuilder() {
277 return AuthQueryBuilder
278 }
279 }
280 }
281}