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, "magical" access control integrated with objection.js
|
17 |
|
18 | 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:
|
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 |
|
25 | 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!
|
26 |
|
27 | ### 🏠 [Homepage](https://github.com/JaneJeon/objection-authorize)
|
28 |
|
29 | ## Install
|
30 |
|
31 | To install the library itself:
|
32 |
|
33 | ```sh
|
34 | yarn add objection objection-authorize # or
|
35 | npm install objection objection-authorize --save
|
36 | ```
|
37 |
|
38 | And 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 |
|
44 | Starting 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 |
|
48 | Plugging in `objection-authorize` to work with your existing authorization setup is as easy as follows:
|
49 |
|
50 | ```js
|
51 | const acl = ... // see below for defining acl
|
52 |
|
53 | const { Model } = require('objection')
|
54 | const authorize = require('objection-authorize')(acl, library, opts) // choose role-acl@3, role-acl@4, or casl for library
|
55 |
|
56 | class Post extends authorize(Model) {
|
57 | // that's it! This is just a regular objection.js model class
|
58 | }
|
59 | ```
|
60 |
|
61 | And that adds a "magic" `authorize(user, resource, opts)` method that can be chained to provide access control and authorize requests.
|
62 |
|
63 | Calling this method will:
|
64 |
|
65 | 1. 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.
|
66 | 2. 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)
|
67 | 3. 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 |
|
69 | Note that the method must be called _before_ any insert/patch/update/delete calls:
|
70 |
|
71 | ```js
|
72 | const post = await Post.query()
|
73 | .authorize(user, resource, opts)
|
74 | .insertAndFetch({ title: 'hello!' }) // authorize a POST request
|
75 | await Post.query().authorize(user, resource, opts).findById(1) // authorize a GET request
|
76 | await post.$query().authorize(user, resource, opts).patch(body).returning('*') // authorize a PATCH request
|
77 | await post.$query().authorize(user, resource, opts).delete() // authorize a DELETE request
|
78 | // it's THAT simple!
|
79 | ```
|
80 |
|
81 | ## Resource
|
82 |
|
83 | A 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 |
|
85 | In 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
|
88 | const post = await Post.query().findById(1)
|
89 | await post
|
90 | .$query()
|
91 | .authorize(user) // resource param not needed
|
92 | .patch(body)
|
93 | ```
|
94 |
|
95 | And when all else has failed, the plugin looks at the resulting object for the resource:
|
96 |
|
97 | ```js
|
98 | await Post.query()
|
99 | .authorize(user) // resource defaults to the result of this Post query
|
100 | .findById(1)
|
101 | ```
|
102 |
|
103 | Furthermore, 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 |
|
105 | In 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 |
|
113 | For `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
|
116 | const RoleAcl = require('role-acl')
|
117 | const acl = new RoleAcl(grants) // if you have a grants object, or
|
118 | const acl = new RoleAcl()
|
119 | acl.grant('user').execute('create').on('Video') // just chain it as usual
|
120 | ```
|
121 |
|
122 | ### @casl/ability
|
123 |
|
124 | 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.
|
125 |
|
126 | 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.
|
127 |
|
128 | So you might define your ability like this (and it doesn't matter if you use `AbilityBuilder` or `Ability`):
|
129 |
|
130 | ```js
|
131 | const { AbilityBuilder } = require('@casl/ability')
|
132 |
|
133 | function 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 |
|
144 | 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:
|
145 |
|
146 | ```js
|
147 | function 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 |
|
164 | One 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 |
|
166 | This 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 |
|
168 | The 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 |
|
170 | So that means before you start your server, you should `await Model.fetchTableMetadata()` for _all_ of your model classes!
|
171 |
|
172 | For example:
|
173 |
|
174 | ```js
|
175 | const modelClasses = [User, Post, Comment]
|
176 | Promise.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 |
|
190 | 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.
|
191 |
|
192 | 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_.
|
193 |
|
194 | 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).
|
195 |
|
196 | 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.
|
197 |
|
198 | ## Authorization Context (for role-acl only)
|
199 |
|
200 | Due 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 |
|
202 | This does mean that there's a potential for key conflicts. In that case, the precedence is as follows:
|
203 |
|
204 | 1. The resource object (attached to top level; its fields are accessible directly by `$.$field`)
|
205 | 2. The query-level/local resource arguments (ditto)
|
206 | 3. The plugin-level/global resource arguments (ditto)
|
207 | 4. Requester/query object (attached under the `req` key: accessible by `$.req.user.$field` and `$.req.body.$field`).
|
208 |
|
209 | So 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 |
|
213 | You 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
|
216 | const 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 |
|
228 | Additionally, 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 |
|
230 | For explanations on what each option does, see below:
|
231 |
|
232 | <details>
|
233 | <summary>defaultRole</summary>
|
234 |
|
235 | When 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 |
|
242 | Error code thrown when an unauthenticated user is not allowed to access a resource.
|
243 |
|
244 | </details>
|
245 |
|
246 | <details>
|
247 | <summary>unauthorizedErrorCode</summary>
|
248 |
|
249 | Error code thrown when an authenticated user is not allowed to access a resource.
|
250 |
|
251 | </details>
|
252 |
|
253 | <details>
|
254 | <summary>userFromResult</summary>
|
255 |
|
256 | There 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 |
|
258 | For 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 |
|
260 | Set 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 |
|
262 | For 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 |
|
264 | To 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
|
267 | const fn = (user, result) =>
|
268 | user instanceof Model && isEqual(user.$id(), result.$id())
|
269 | ```
|
270 |
|
271 | </details>
|
272 |
|
273 | <details>
|
274 | <summary>contextKey</summary>
|
275 |
|
276 | As 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 |
|
283 | With `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 |
|
290 | Since 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 |
|
292 | So 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 |
|
298 | In 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 |
|
302 | This 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 |
|
304 | This 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 |
|
306 | Here's how it might work with express:
|
307 |
|
308 | ```js
|
309 | app
|
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 |
|
343 | For 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
|
348 | yarn test
|
349 | ```
|
350 |
|
351 | ## Author
|
352 |
|
353 | 👤 **Jane Jeon**
|
354 |
|
355 | - Github: [@JaneJeon](https://github.com/JaneJeon)
|
356 |
|
357 | ## 🤝 Contributing
|
358 |
|
359 | Contributions, 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 |
|
363 | Give a ⭐️ if this project helped you!
|
364 |
|
365 | ## 📝 License
|
366 |
|
367 | Copyright © 2019 [Jane Jeon](https://github.com/JaneJeon).<br />
|
368 | This project is [MIT](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE) licensed.
|