1 | <h1 align="center">Welcome to objection-authorize 👋</h1>
|
2 |
|
3 | [![CI](https://github.com/JaneJeon/objection-authorize/actions/workflows/ci.yml/badge.svg)](https://github.com/JaneJeon/objection-authorize/actions/workflows/ci.yml)
|
4 | [![NPM](https://img.shields.io/npm/v/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
|
5 | [![Downloads](https://img.shields.io/npm/dt/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
|
6 | [![Docs](https://img.shields.io/badge/docs-github-blue)](https://janejeon.github.io/objection-authorize)
|
7 | [![Prettier code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
|
8 |
|
9 | > isomorphic, "magical" access control integrated with objection.js
|
10 |
|
11 | This plugin automatically takes away a lot of the manual wiring that you'd need to do if you were to implement your access control on a request/route level, including:
|
12 |
|
13 | - checking the user against the resource and the ACL
|
14 | - filtering request body according to the action and the user's access
|
15 | - figuring out _which_ resource to check the user's grants against automatically(!)
|
16 | - even filtering the result from a query according to a user's read access!
|
17 |
|
18 | Not sure why you would need this? Read below for examples or [see here](https://janejeon.dev/integrating-access-control-to-your-node-js-apps) to learn just how complex access control can be and how you can manage said complexity with this plugin!
|
19 |
|
20 | **TL;DR:**
|
21 |
|
22 | Before:
|
23 |
|
24 | ```js
|
25 | class Post extends Model {}
|
26 |
|
27 | app.put('/posts/:id', (req, res, next) => {
|
28 | // Need to handle random edge cases like the user not being signed in
|
29 | if (!req.user) next(new Error('must be signed in'))
|
30 |
|
31 | // Need to first fetch the post to know "can this user edit this post?"
|
32 | Post.query()
|
33 | .findById(req.params.id)
|
34 | .then(post => {
|
35 | if (req.user.id !== post.authorId || req.user.role !== 'editor')
|
36 | return next(new Error("Cannot edit someone else's post!"))
|
37 |
|
38 | // Prevent certain fields from being set after creation
|
39 | const postBody = omit(req.body, ['id', 'views', 'authorId'])
|
40 |
|
41 | // Prevent certain fields from being *changed*
|
42 | if (
|
43 | post.visibility === 'public' &&
|
44 | get(postBody, 'visibility') !== post.visibility &&
|
45 | req.user.role !== 'admin'
|
46 | )
|
47 | return next(
|
48 | new Error('Cannot take down a post without admin privileges!')
|
49 | )
|
50 |
|
51 | req.user
|
52 | .$relatedQuery('posts')
|
53 | .updateAndFetchById(post.id, postBody)
|
54 | .then(post => {
|
55 | // filter the resulting post based on user's access before sending it over
|
56 | if (req.user.role !== 'admin') post = omit(post, ['superSecretField'])
|
57 |
|
58 | res.send(post)
|
59 | })
|
60 | .catch(err => next(err))
|
61 | })
|
62 | .catch(err => next(err))
|
63 | })
|
64 |
|
65 | // And you need to repeat ALL of this validation on the frontend as well...
|
66 | ```
|
67 |
|
68 | After:
|
69 |
|
70 | ```js
|
71 | // Use the plugin...
|
72 | class Post extends require('objection-authorize')(acl, library, opts)(Model) {}
|
73 |
|
74 | app.put('/posts/:id', (req, res, next) => {
|
75 | // ...and the ACL is automagically hooked in for ALL queries!
|
76 | Post.query()
|
77 | .updateAndFetchById(req.params.id, req.body)
|
78 | .authorize(req.user)
|
79 | .fetchResourceContextFromDB()
|
80 | .diffInputFromResource()
|
81 | .then(post => {
|
82 | res.send(post.authorizeRead(req.user))
|
83 | })
|
84 | .catch(err => next(err))
|
85 | })
|
86 |
|
87 | // AND you can re-use the ACL on the frontend as well *without* any changes!
|
88 | ```
|
89 |
|
90 | ### 🏠 [Homepage](https://github.com/JaneJeon/objection-authorize)
|
91 |
|
92 | > Enjoy objection-authorize? Check out my other objection plugins: [objection-hashid](https://github.com/JaneJeon/objection-hashid) and [objection-tablename](https://github.com/JaneJeon/objection-table-name)!
|
93 |
|
94 | ## Installation
|
95 |
|
96 | To install the plugin itself:
|
97 |
|
98 | ```sh
|
99 | yarn add objection-authorize # or
|
100 | npm i objection-authorize --save
|
101 | ```
|
102 |
|
103 | For now, only `@casl/ability` is supported as the authorization library, but this plugin is written in an implementation-agnostic way so that any AuthZ/ACL library could be implemented as long as the library of choice supports _synchronous_ authorization checks.
|
104 |
|
105 | ## Changelog
|
106 |
|
107 | Starting from the 1.0 release, all changes will be documented at the [releases page](https://github.com/JaneJeon/objection-authorize/releases).
|
108 |
|
109 | ## Terminology
|
110 |
|
111 | A quick note, I use the following terms interchangeably:
|
112 |
|
113 | - `resource` and `item(s)` (both refer to model instance(s) that the query is fetching/modifying)
|
114 | - `body` and `input` and `inputItem(s)` (all of them refer to the `req.body`/`ctx.body` that you pass to the query to modify said model instances; e.g. `Model.query().findById(id).update(inputItems)`)
|
115 |
|
116 | ## Usage
|
117 |
|
118 | Plugging in `objection-authorize` to work with your existing authorization setup is as easy as follows:
|
119 |
|
120 | ```js
|
121 | const acl = ... // see below for defining acl
|
122 |
|
123 | const { Model } = require('objection')
|
124 | const authorize = require('objection-authorize')(acl, library[, opts])
|
125 |
|
126 | class Post extends authorize(Model) {
|
127 | // That's it! This is just a regular objection.js model class
|
128 | }
|
129 | ```
|
130 |
|
131 | ### Options
|
132 |
|
133 | You can pass an _optional_ options object as the third parameter during initialization. The default values are as follows:
|
134 |
|
135 | ```js
|
136 | const opts = {
|
137 | defaultRole: 'anonymous',
|
138 | unauthenticatedErrorCode: 401,
|
139 | unauthorizedErrorCode: 403,
|
140 | castDiffToModelClass: true,
|
141 | ignoreFields: [],
|
142 | casl: {
|
143 | useInputItemAsResourceForRelation: false
|
144 | }
|
145 | }
|
146 | ```
|
147 |
|
148 | For explanations on what each option does, see below:
|
149 |
|
150 | <details>
|
151 | <summary>defaultRole</summary>
|
152 |
|
153 | When the user object is empty, a "default" user object will be created with the `defaultRole` (e.g. `{ role: opts.defaultRole }`).
|
154 |
|
155 | </details>
|
156 |
|
157 | <details>
|
158 | <summary>unauthenticatedErrorCode</summary>
|
159 |
|
160 | Error code thrown when an unauthenticated user is not allowed to access a resource.
|
161 |
|
162 | </details>
|
163 |
|
164 | <details>
|
165 | <summary>unauthorizedErrorCode</summary>
|
166 |
|
167 | Error code thrown when an authenticated user is not allowed to access a resource.
|
168 |
|
169 | </details>
|
170 |
|
171 | <details>
|
172 | <summary>castDiffToModelClass</summary>
|
173 |
|
174 | When you use `.diffInputFromResource()`, the resource and the inputItem are compared and a diff (an object containing the changes) is fed to your access control checker.
|
175 |
|
176 | Since the diff is produced as a plain object, we need to cast it to the appropriate model class again so that you can access that model's methods and model-specific fields.
|
177 |
|
178 | However, in some cases (such as when you're doing some bespoke field/value remapping in `Model.$parseJson()`), casting the object to the model class isn't "safe" to do, and the resulting model instance might contain different values from the raw diff object.
|
179 |
|
180 | If you want to disable it, just set `opts.castDiffToModelClass` to false and the raw diff object will be fed to the access control functions.
|
181 |
|
182 | </details>
|
183 |
|
184 | <details>
|
185 | <summary>ignoreFields</summary>
|
186 |
|
187 | When you automatically modify/include some fields (e.g. automatic timestamps) in your Objection models, as objection-authorize is typically the "last" hook to run before execution, the policies will check for those fields as well.
|
188 |
|
189 | These allow you to ignore those fields in authorization decisions. Note that you can specify the fields in dot notation as well (e.g. `timestamp.updatedAt`).
|
190 |
|
191 | </details>
|
192 |
|
193 | <details>
|
194 | <summary>casl.useInputItemAsResourceForRelation</summary>
|
195 |
|
196 | Normally, the `item` is used as "resource" since that's what the user is acting _on_.
|
197 |
|
198 | However, for relation queries (e.g. add `Book` to a `Library`), the user is _really_ acting on the `Book`, not the `Library`. For cases like this, you can set this option to `true` in order to use the `inputItem` (`Book`) as "resource" instead of `item` (`Library`) **ONLY** during relation queries.
|
199 |
|
200 | </details>
|
201 |
|
202 | ### Methods
|
203 |
|
204 | After initialization, the following "magic" methods are available for use:
|
205 |
|
206 | <details>
|
207 | <summary>QueryBuilder.authorize(user[, resource[, opts]])</summary>
|
208 |
|
209 | This is the bread and butter of this library. You can chain `.authorize()` to any Objection Model query (i.e. `Model.query().authorize()`) to authorize that specific ORM call/HTTP request.
|
210 |
|
211 | First, an explanation of the parameters:
|
212 |
|
213 | The `user` should be an object representation of the user; typically, you can just plug in `req.user` (express) or `ctx.user` (koa) directly, _even if the user is not signed in_ (aka `req.user === undefined`)!
|
214 |
|
215 | The `resource` object is an optional parameter, and for most queries, you won't need to manually specify the resource.
|
216 |
|
217 | The `opts` can be used to override any of the default options that you passed during initialization of this plugin (i.e. you don't have to pass the whole options object in; only the parts you want to override for this specific query).
|
218 |
|
219 | So, what are we _actually_ checking here with this function?
|
220 |
|
221 | When you chain `.authorize()` to the ORM query, the query is (typically) doing one of four things: create, read, update, or delete (CRUD) - which is the action they're trying to take. These correspond to the HTTP verbs: GET/POST/PUT/PATCH/DELETE (if you're not familiar with how this is the case, please read up on REST API design).
|
222 |
|
223 | In addition, the query already provides the following contexts: the resource/item(s) that the user is acting on (e.g. read a **user**'s email, or create a **post**), the body/inputItem(s) that the user is supplying. This is typically the `req.body` that you pass to the `.insert()/.update()/.delete()` query methods, aka _how_ you want to change the resource.
|
224 |
|
225 | So, given this information, we can just rely on the ACL (see below for how to define it) to check whether the `user` is allowed to take the specified `action` on `resource/items` with the given `body/inputItems`! Specifically, the authorization check involves the following functionalities:
|
226 |
|
227 | 1. Check if the user is allowed to apply the specified `action` on the `items`, and if not, throw an `httpError` with the appropriate HTTP error code
|
228 | 2. If there's `inputItems`, check if the user is allowed to modify/add the specific fields in `inputItems`. If a user tries to set/modify a property they're not allowed to, error is thrown again.
|
229 |
|
230 | That's it!
|
231 |
|
232 | The nuances of this plugin comes with how it's able to drastically simplify said ACL calls & context fetching. For example, while figuring out the `inputItems` might be simple, how does the plugin know which `items` the `action` applies to?
|
233 |
|
234 | The plugin looks at the following places to fetch the appropriate `resource(s)`:
|
235 |
|
236 | 1. If the `resource` parameter is specified in the `.authorize()` call, it takes precedence and is set as the only item(s) that we check against.
|
237 | 2. If the `resource` parameter is not specified, then it looks at the model instance (if you're calling `.$query()` or `.$relatedQuery()`)
|
238 | 3. If you call `.fetchContextFromDB()`, then the plugin executes a pre-emptive SQL SELECT call to fetch the rows that the query would affect.
|
239 |
|
240 | And once the plugin figures out `items` and `inputItems`, it simply iterates along both arrays and checks the ACL whether the user can take `action` on `items[i]` with input `inputItems[j]`.
|
241 |
|
242 | That's it.
|
243 |
|
244 | **TIP**: the `.authorize()` call can happen _anywhere_ within the query chain!
|
245 |
|
246 | </details>
|
247 |
|
248 | <details>
|
249 | <summary>QueryBuilder.action(action)</summary>
|
250 |
|
251 | Rather than using the "default" actions (create/read/update/delete), you can override the action per query.
|
252 |
|
253 | This is useful when you have custom actions in your ACL (such as `promote`) for a specific endpoint/query. Just chain a `.action(customAction)` somewhere in the query (in this case, the `customAction` would be `"promote"`).
|
254 |
|
255 | </details>
|
256 |
|
257 | <details>
|
258 | <summary>QueryBuilder.inputItem(inputItem)</summary>
|
259 |
|
260 | For methods that don't support passing `inputItem(s)` (e.g. `.delete()`) but you still want to set the input item/resource, you can call this method to manually override the value of the resource used by the ACL.
|
261 |
|
262 | </details>
|
263 |
|
264 | <details>
|
265 | <summary>QueryBuilder.fetchResourceContextFromDB()</summary>
|
266 |
|
267 | Sometimes, you need to know the values of the resource(s) you're trying to access before you can make an authorization decision. So instead of loading the model instance(s) yourself and running `.$query()` on them, you can chain `.fetchResourceContextFromDB()` to your query and automatically populate the `inputs`/resources that would've been affected by the query.
|
268 |
|
269 | e.g.
|
270 |
|
271 | ```js
|
272 | await Person.query()
|
273 | .authorize(user)
|
274 | .where('lastName', 'george')
|
275 | .update({ lastName: 'George' }) // input item
|
276 | .fetchResourceContextFromDB() // Loads all people that would be affected by the update,
|
277 | // and runs authorization check on *all* of those individuals against the input item.
|
278 | ```
|
279 |
|
280 | </details>
|
281 |
|
282 | <details>
|
283 | <summary>QueryBuilder.diffInputFromResource()</summary>
|
284 |
|
285 | This method is particularly useful for UPDATE requests, where the client is sending the _entire_ object (rather than just the changes, like PATCH). Obviously, if you put the whole object through the AuthZ check, it will trip up (for example, the client may include the object's id as part of an UPDATE request, and you don't want the ACL to think that the client is trying to change the id)!
|
286 |
|
287 | Therefore, call this method anywhere along the query chain, and the plugin will automatically diff the input object(s) with whatever the resource is! The beauty of this method is that it also works for _nested fields_, so even if your table includes a JSON field, only the exact diff - all the way down to the nested subfields - will be passed along to the ACL.
|
288 |
|
289 | e.g.
|
290 |
|
291 | ```js
|
292 | Model.query()
|
293 | .authorize(user, { id: 1, foo: { bar: 'baz', a: 0 } })
|
294 | .updateById(id, { id: 1, foo: { bar: 'baz', b: 0 } })
|
295 | .diffInputFromResource() // the diff will be { foo: { b: 0 } }
|
296 | ```
|
297 |
|
298 | **NOTE**: the plugin is ONLY able to detect changes to an existing field's value or an addition of a _new_ field, NOT the deletion of an existing field (see above how the implicit deletion of `foo.a` is not included in the diff).
|
299 |
|
300 | Therefore, care must be taken during UPDATE queries where fields (_especially_ nested fields) may be added/removed dynamically. Having JSON subfields doesn't mean you throw out schema Mongo-style; so if you need to monitor for _deletion_ of a field (rather than mutation or addition), I would recommend assigning all of the possible fields' value with `null`, rather than leaving it out entirely, so that deletions would show up as mutations.
|
301 |
|
302 | e.g. in the above case, if you wanted to check whether field `foo.a` was deleted or not:
|
303 |
|
304 | ```js
|
305 | resource = { id: 1, foo: { bar: 'baz', a: 0, b: null } }
|
306 | input = { id: 1, foo: { bar: 'baz', a: null, b: 0 } }
|
307 | ```
|
308 |
|
309 | </details>
|
310 |
|
311 | <details>
|
312 | <summary>modelInstance.authorizeRead(user, [action = 'read'[, opts]])</summary>
|
313 |
|
314 | Prior to objection-authorize v4, the plugin "automatically" filtered any resulting model instances against a user's read access, but it didn't work consistently and I found it to be too hacky, so from v4 and on, you will need to manually call the `.authorizeRead()` on your model instance to filter it according to the user's read access (which can be overridden with the `action` parameter).
|
315 |
|
316 | This call is synchronous and will return the filtered model instance directly. Note that the result is a plain object, not an instance of the model _class_ anymore, since this call is meant to be for "finalizing" the model instance for returning to the user as a raw JSON.
|
317 |
|
318 | </details>
|
319 |
|
320 | ## Defining the ACL
|
321 |
|
322 | The ACL is what actually checks the validity of a request, and `objection-authorize` passes all of the necessary context in the form of function parameters (thus, you should wrap your ACL in the following function format):
|
323 |
|
324 | ```js
|
325 | function acl(user, resource, action, body, opts) {
|
326 | // your ACL definition goes here
|
327 | }
|
328 | ```
|
329 |
|
330 | **NOTE**: while `user` is cast into plain object form (simply due to the fact that `req.user` could be empty, and we would need to create a "fake" user with a default role), `resource` and `body` (aka `item` and `inputItem`) are cast into their respective _Models_ - this is to maintain consistency with the internal Objection.js static hooks' behaviour.
|
331 |
|
332 | For example, in a query:
|
333 |
|
334 | ```js
|
335 | await Person.relatedQuery('pets')
|
336 | .for([1, 2])
|
337 | .insert([{ name: 'doggo' }, { name: 'catto' }])
|
338 | .authorize(user)
|
339 | .fetchContextFromDB()
|
340 | ```
|
341 |
|
342 | The `resource` is an instance of model `Person`, and the `body` is an instance of model `Pet`. How do I know what class to wrap it in? Magic! ;)
|
343 |
|
344 | ### @casl/ability
|
345 |
|
346 | For `casl`, because it doesn't allow dynamically checking against any resource or action, we have to wrap it with a function, and that function takes in `(user, resource, action, body, opts)` and returns an _instance_ of ability.
|
347 |
|
348 | This is essentially the same as the `defineAbilitiesFor(user)` method described [in the casl docs](https://stalniy.github.io/casl/abilities/2017/07/20/define-abilities.html), but obviously with a lot more context.
|
349 |
|
350 | So you might define your ability like this (and it doesn't matter if you use `AbilityBuilder` or `Ability`):
|
351 |
|
352 | ```js
|
353 | const { AbilityBuilder } = require('@casl/ability')
|
354 |
|
355 | function acl(user, resource, action, body, opts) {
|
356 | return AbilityBuilder.define((allow, forbid) => {
|
357 | if (user.isAdmin()) {
|
358 | allow('manage', 'all')
|
359 | } else {
|
360 | allow('read', 'all')
|
361 | }
|
362 | })
|
363 | }
|
364 | ```
|
365 |
|
366 | **TIP**: If you want to cut down on the time it takes to check access, one thing you might want to do is to use the `resource` parameter to ONLY define rules relevant to that resource:
|
367 |
|
368 | ```js
|
369 | function acl(user, resource, action, body, opts) {
|
370 | return AbilityBuilder.define((allow, forbid) => {
|
371 | switch (resource.constructor.name) {
|
372 | case 'User':
|
373 | allow('read', 'User')
|
374 | forbid('read', 'User', ['email'])
|
375 | break
|
376 | case 'Post':
|
377 | allow('create', 'Post')
|
378 | forbid('read', 'Post', { private: true })
|
379 | }
|
380 | })
|
381 | }
|
382 | ```
|
383 |
|
384 | ### Note on Resource Names
|
385 |
|
386 | _For both libraries_, note that the resource name IS the corresponding model's name. So if you have a model class `Post`, you should be referring to that resource as `Post` and not `post` in your ACL definition.
|
387 |
|
388 | ### Note on Sharing the ACL between frontend and the backend
|
389 |
|
390 | The resources that are passed to this plugin in the backend are typically going to be wrapped in their respective model classes: e.g. `req.user` typically will be an instance of the `User` class, and the resource will _always_ be wrapped with its respective class.
|
391 |
|
392 | So if you want to share your ACL between frontend and the backend, as the frontend doesn't have access to Objection models, any transformation you have on your models should be _symmetric_.
|
393 |
|
394 | For example, if you have `user.id` and `post.creatorId` and you hash ID's when you export it to JSON, you want to make sure if `user.id = post.creatorId = 1`, the transformed values are _also_ the same (`user.id = post.creatorId = XYZ`, for example).
|
395 |
|
396 | This also means that you _shouldn't_ rely on virtuals and asymmetrically-transformed fields on your ACL (if you want to use your ACL on the frontend, that is). For an example of symmetric transformation out in the wild, see https://github.com/JaneJeon/objection-hashid.
|
397 |
|
398 | ## Relation support
|
399 |
|
400 | With objection-authorize v4, I added _experimental_ relation support, so on your ACL wrapper (the function that takes in 5 parameters - I really should just wrap them in an object but that would break compatibility), now there is an optional, 6th parameter called `relation`:
|
401 |
|
402 | ```js
|
403 | function acl(user, resource, action, body, opts, relation) {
|
404 | // your ACL definition goes here
|
405 | }
|
406 | ```
|
407 |
|
408 | And that `relation` property is simply a string representation of the relation between `item` and `inputItem` that you specified in the resource model's `relationMappings`. So you can use that `relation` key to detect relations and do fancy things with it.
|
409 |
|
410 | In reality, most of the relation support is well-tested and already proven to be working, as the hardest part was to wrap the `inputItem` in the appropriate related class (rather than using the same class for both the `item` and `inputItem`); it's just that I can't test the `relation` string itself due to some... Objection finnickyness.
|
411 |
|
412 | ## Run tests
|
413 |
|
414 | ```sh
|
415 | npm test
|
416 | ```
|
417 |
|
418 | ## Author
|
419 |
|
420 | 👤 **Jane Jeon**
|
421 |
|
422 | - Github: [@JaneJeon](https://github.com/JaneJeon)
|
423 |
|
424 | ## 🤝 Contributing
|
425 |
|
426 | Contributions, issues and feature requests are welcome!<br />Feel free to check [issues page](https://github.com/JaneJeon/objection-authorize/issues).
|
427 |
|
428 | ## Show your support
|
429 |
|
430 | Give a ⭐️ if this project helped you!
|
431 |
|
432 | ## 📝 License
|
433 |
|
434 | Copyright © 2021 [Jane Jeon](https://github.com/JaneJeon).<br />
|
435 | This project is [LGPL](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE) licensed (TL;DR: please contribute back any improvements to this library).
|