UNPKG

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