UNPKG

17.7 kBMarkdownView Raw
1<h1 align="center">Welcome to objection-authorize 👋</h1>
2
3[![CircleCI](https://circleci.com/gh/JaneJeon/objection-authorize.svg?style=shield)](https://circleci.com/gh/JaneJeon/objection-authorize)
4[![Coverage](https://codecov.io/gh/JaneJeon/objection-authorize/branch/master/graph/badge.svg)](https://codecov.io/gh/JaneJeon/objection-authorize)
5[![NPM](https://img.shields.io/npm/v/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
6[![Downloads](https://img.shields.io/npm/dt/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
7[![install size](https://packagephobia.now.sh/badge?p=objection-authorize)](https://packagephobia.now.sh/result?p=objection-authorize)
8[![David](https://img.shields.io/david/JaneJeon/objection-authorize)](https://david-dm.org/JaneJeon/objection-authorize)
9[![Known Vulnerabilities](https://snyk.io//test/github/JaneJeon/objection-authorize/badge.svg?targetFile=package.json)](https://snyk.io//test/github/JaneJeon/objection-authorize?targetFile=package.json)
10[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=JaneJeon/objection-authorize)](https://dependabot.com)
11[![License](https://img.shields.io/npm/l/objection-authorize)](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE)
12[![Docs](https://img.shields.io/badge/docs-github-blue)](https://janejeon.github.io/objection-authorize)
13[![Standard code style](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
14[![Prettier code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
15
16> isomorphic, &#34;magical&#34; access control integrated with objection.js
17
18This 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:
19
20- checking the user against the resource and the ACL
21- filtering request body according to the action and the user's access
22- figuring out _which_ resource to check the user's grants against automatically(!)
23- even filtering the result from a query according to a user's read access!
24
25Not 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!
26
27### 🏠 [Homepage](https://github.com/JaneJeon/objection-authorize)
28
29## Install
30
31To install the library itself:
32
33```sh
34yarn add objection objection-authorize # or
35npm install objection objection-authorize --save
36```
37
38And you can install either [role-acl](https://github.com/tensult/role-acl) or [@casl/ability](https://github.com/stalniy/casl) as your authorization framework. Note that `role-acl>=4 <4.3.2` (that is every v4 release before v4.3.2) is NOT supported as the library author just dropped synchronous acl support overnight.
39
40**NOTE**: this plugin works with and is tested against _both_ objection v1 and v2!
41
42## Changelog
43
44Starting from the 1.0 release, all changes will be documented at the [releases page](https://github.com/JaneJeon/objection-authorize/releases).
45
46## Usage
47
48Plugging in `objection-authorize` to work with your existing authorization setup is as easy as follows:
49
50```js
51const acl = ... // see below for defining acl
52
53const { Model } = require('objection')
54const authorize = require('objection-authorize')(acl, library, opts) // choose role-acl@3, role-acl@4, or casl for library
55
56class Post extends authorize(Model) {
57 // that's it! This is just a regular objection.js model class
58}
59```
60
61And that adds a "magic" `authorize(user, resource, opts)` method that can be chained to provide access control and authorize requests.
62
63Calling this method will:
64
651. check that a user is allowed to perform an action based on the acl, resource, action, body, & the user and throw an error if they're not allowed to.
662. filter the request body (i.e. the thing you pass to `create()/update()/delete()`) according to the user's access (this is not relevant for GET operations)
673. if there's a returning result (e.g. you called `.returning('*')` or `(insert|update)AndFetch(byId)`), filters that according to a user's read access
68
69Note that the method must be called _before_ any insert/patch/update/delete calls:
70
71```js
72const post = await Post.query()
73 .authorize(user, resource, opts)
74 .insertAndFetch({ title: 'hello!' }) // authorize a POST request
75await Post.query().authorize(user, resource, opts).findById(1) // authorize a GET request
76await post.$query().authorize(user, resource, opts).patch(body).returning('*') // authorize a PATCH request
77await post.$query().authorize(user, resource, opts).delete() // authorize a DELETE request
78// it's THAT simple!
79```
80
81## Resource
82
83A resource can be a plain object or an instance of a `Model` class (or any of its subclasses) that specifies _what_ the user is trying to access.
84
85In absence of the `resource` parameter in `authorize(user, resource, opts)`, this plugin attempts to load the resource from the model instance, so if you've already fetched a resource and are calling `$query()` to build a query, that will be used as the resource automatically:
86
87```js
88const post = await Post.query().findById(1)
89await post
90 .$query()
91 .authorize(user) // resource param not needed
92 .patch(body)
93```
94
95And when all else has failed, the plugin looks at the resulting object for the resource:
96
97```js
98await Post.query()
99 .authorize(user) // resource defaults to the result of this Post query
100 .findById(1)
101```
102
103Furthermore, if you're creating a resource (i.e. `insert()`), you do not have to specify the resource, though the result from the query will be filtered according to the user's read access.
104
105In general, you do not have to specify the resource parameter unless you want to force the plugin into using a particular resource.
106
107[See here for more examples](https://github.com/JaneJeon/objection-authorize/blob/master/test/utils/plugin-test.js).
108
109## Defining the ACL
110
111### role-acl
112
113For `role-acl`, just define the acl as you normally would. Note that you're meant to pass the formed acl instead of the grants object:
114
115```js
116const RoleAcl = require('role-acl')
117const acl = new RoleAcl(grants) // if you have a grants object, or
118const acl = new RoleAcl()
119acl.grant('user').execute('create').on('Video') // just chain it as usual
120```
121
122### @casl/ability
123
124For `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.
125
126This 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.
127
128So you might define your ability like this (and it doesn't matter if you use `AbilityBuilder` or `Ability`):
129
130```js
131const { AbilityBuilder } = require('@casl/ability')
132
133function acl(user, resource, action, body, opts) {
134 return AbilityBuilder.define((allow, forbid) => {
135 if (user.isAdmin()) {
136 allow('manage', 'all')
137 } else {
138 allow('read', 'all')
139 }
140 })
141}
142```
143
144If 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:
145
146```js
147function acl(user, resource, action, body, opts) {
148 return AbilityBuilder.define((allow, forbid) => {
149 switch (resource.constructor.name) {
150 case 'User':
151 allow('read', 'User')
152 forbid('read', 'User', ['email'])
153 break
154 case 'Post':
155 allow('create', 'Post')
156 forbid('read', 'Post', { private: true })
157 }
158 })
159}
160```
161
162#### IMPORTANT NOTE WHEN USING CASL
163
164One key difference between `casl` and `role-acl` is that `role-acl` has a concept of _negating_ a field (e.g. `!field` means filter _out_ the `field`).
165
166This means that `casl` only has a notion of what fields _should_ be included. However, it can't know what fields need to be included from just looking at the ACL, and we have no way of reliably getting the list of fields for a model without calling a static model method, `tableMetadata()`.
167
168The good news is that this is _synchronous_, allowing it to fit within the `objection-authorize` plugin lifecycle. The bad news is that this is fetched from a cache, and we need to _pre-populate_ the cache BEFORE we even call `.authorize()` even once!
169
170So that means before you start your server, you should `await Model.fetchTableMetadata()` for _all_ of your model classes!
171
172For example:
173
174```js
175const modelClasses = [User, Post, Comment]
176Promise.all(
177 modelClasses.map(modelClass => modelClass.fetchTableMetadata())
178).then(() => {
179 // start your server
180 app.listen(3000)
181})
182```
183
184### Note on Resource Names
185
186_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.
187
188### Note on Sharing the ACL between frontend and the backend
189
190The 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.
191
192So 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_.
193
194For 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).
195
196This 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.
197
198## Authorization Context (for role-acl only)
199
200Due to the limitations of `role-acl`, authorization context (i.e. the right side of access condition arguments) combines the actual resource object, requester, the query object (the stuff you pass to `Model.query().update(obj)` and the likes), resource argument options (global and local) into one object.
201
202This does mean that there's a potential for key conflicts. In that case, the precedence is as follows:
203
2041. The resource object (attached to top level; its fields are accessible directly by `$.$field`)
2052. The query-level/local resource arguments (ditto)
2063. The plugin-level/global resource arguments (ditto)
2074. Requester/query object (attached under the `req` key: accessible by `$.req.user.$field` and `$.req.body.$field`).
208
209So if resource (priority 1) had a property called `req`, then the requester and query object (priority 2) would be overwritten and be inaccessible under `$.req.(user|body)`.
210
211## Options
212
213You can pass an options object as the second parameter in `objectionAuthorize(acl, opts)` when initializing the plugin. The options objects is structured as follows (the given values are the default):
214
215```js
216const opts = {
217 defaultRole: 'anonymous',
218 unauthenticatedErrorCode: 401,
219 unauthorizedErrorCode: 403,
220 userFromResult: false,
221 // below are role-acl specific options
222 contextKey: 'req',
223 roleFromUser: user => user.role,
224 resourceAugments: { true: true, false: false, undefined: undefined }
225}
226```
227
228Additionally, you can override the settings on an individual query basis. Just pass the `opts` as the 3rd parameter of `authorize(user, resource, opts)` to override the "global" opts that you set while initializing the plugin _just_ for that query.
229
230For explanations on what each option does, see below:
231
232<details>
233<summary>defaultRole</summary>
234
235When the user object is empty, a "default" user object will be created with the `defaultRole`.
236
237</details>
238
239<details>
240<summary>unauthenticatedErrorCode</summary>
241
242Error code thrown when an unauthenticated user is not allowed to access a resource.
243
244</details>
245
246<details>
247<summary>unauthorizedErrorCode</summary>
248
249Error code thrown when an authenticated user is not allowed to access a resource.
250
251</details>
252
253<details>
254<summary>userFromResult</summary>
255
256There might be situations where a query (possibly) changes the requesting user itself. In that case, we need to update the user context in order to get accurate read access on the returning result.
257
258For instance, if other people can't read a user's email address, when you create/update a user, the returning result might have the email address filtered out because the original user context was an anonymous user.
259
260Set to `true` to "refresh" the user context, or pass a function to _ensure_ that the changed user IS the user that requested the query. The function takes in `(user, result)` and returns a `boolean`.
261
262For example, you might use the function when admins can change a user's details, but the changed user _might_ be the admin itself or it could be someone different.
263
264To ensure the admin only sees the email address when the changed user is actually the admin itself, you might want to pass a function checking that the requesting user IS the changed user, like this:
265
266```js
267const fn = (user, result) =>
268 user instanceof Model && isEqual(user.$id(), result.$id())
269```
270
271</details>
272
273<details>
274<summary>contextKey</summary>
275
276As we gather various context (e.g. user, body, etc) throughout the query building process, we need to mount them to some key at the end, so you can access them via `$.req.user`, for example.
277
278</details>
279
280<details>
281<summary>roleFromUser</summary>
282
283With `casl`, because we're wrapping the acl in a function, we can extract the role from the user however we'd like. However, for `role-acl`, we need to extract a single string. This option is here just in case you have user role under a different key than `user.role`.
284
285</details>
286
287<details>
288<summary>resourceAugments</summary>
289
290Since neither role-acl nor accesscontrol allow you to _just_ check the request body (they don't parse the `$.foo.bar` syntax for the ACL rule keys), if you want to check _only_ the request, you need to put custom properties.
291
292So the default allows checks such as `{Fn: 'EQUALS', args: {true: $.req.body.confirm}}` (useful when trying to require that a user confirm deletion of a resource, for example) by attaching the "true" and "false" values as part of the property of the resource!!
293
294</details>
295
296### Specifying action per query
297
298In addition to the above options, you can also specify the action per query. This is useful when you have custom actions in your ACL (such as `promote`). Just chain a `.action(customAction)` to the query and that will override the default action (`create`/`read`/`update`/`delete`) when checking access!
299
300## Authorizing requests
301
302This works with any router framework (express/koa/fastify/etc) - all you need to do is to provide the requesting user. So instead of `user` in the above examples, you would replace it with `req.user` (for express, for example).
303
304This plugin is agnostic about your choice of authentication - doesn't matter if it's `req.user` or `ctx.user`, or if you're using sessions or JWTs, or if you're using passportjs or something else, all it needs is a user and a resource (optional).
305
306Here's how it might work with express:
307
308```js
309app
310 // for this request, you might want to show the email only if the user is requesting itself
311 .get('/users/:username', async (req, res) => {
312 const username = req.params.username.toLowerCase()
313 const user = await User.query().authorize(req.user).findOne({ username })
314
315 res.send(user)
316 })
317 // for this request, you might want to only allow anonymous users to create an account,
318 // and prevent them from writing anything they're not allowed to (e.g. id/role)
319 .post('/users', async (req, res) => {
320 const user = await User.query()
321 .authorize(req.user, null, {
322 unauthorizedErrorCode: 405, // if you're logged in, then this method is not allowed
323 userFromResult: true // include this to "update" the requester so that they can see their email
324 })
325 .insert(req.body)
326 .returning('*')
327
328 // then login with the user...
329 })
330 // standard "authorize user before they can access/change resources" scheme here:
331 .patch('/users/:username', async (req, res) => {
332 const username = req.params.username.toLowerCase()
333 // we fetch the user first to provide resource context for the authorize() call.
334 // Note that if we were to just call User.query().patchAndFetchById() and skip resource,
335 // then the requester would be able to modify any user before we can even authorize them!
336 let user = await User.query().findOne({ username })
337 user = await user.$query().authorize(req.user).patchAndFetch(req.body)
338
339 res.send(user)
340 })
341```
342
343For a real-life example, see here: https://github.com/JaneJeon/express-objection-starter/blob/master/routes/users.js
344
345## Run tests
346
347```sh
348yarn test
349```
350
351## Author
352
353👤 **Jane Jeon**
354
355- Github: [@JaneJeon](https://github.com/JaneJeon)
356
357## 🤝 Contributing
358
359Contributions, issues and feature requests are welcome!<br />Feel free to check [issues page](https://github.com/JaneJeon/objection-authorize/issues).
360
361## Show your support
362
363Give a ⭐️ if this project helped you!
364
365## 📝 License
366
367Copyright © 2019 [Jane Jeon](https://github.com/JaneJeon).<br />
368This project is [MIT](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE) licensed.