Skip to content
This repository has been archived by the owner on Jan 12, 2023. It is now read-only.

Commit

Permalink
works with ACL4 (breaking version)
Browse files Browse the repository at this point in the history
  • Loading branch information
JaneJeon committed Oct 12, 2019
1 parent ed2d056 commit 167cf46
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 105 deletions.
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
<h1 align="center">Welcome to objection-authorize 👋</h1>

[![CircleCI](https://img.shields.io/circleci/build/github/JaneJeon/objection-authorize)](https://circleci.com/gh/JaneJeon/objection-authorize) [![codecov](https://codecov.io/gh/JaneJeon/objection-authorize/branch/master/graph/badge.svg)](https://codecov.io/gh/JaneJeon/objection-authorize) [![Maintainability](https://api.codeclimate.com/v1/badges/78bae22810143ad84ef1/maintainability)](https://codeclimate.com/github/JaneJeon/objection-authorize/maintainability) [![NPM](https://img.shields.io/npm/v/objection-authorize)](https://www.npmjs.com/package/objection-authorize) [![Downloads](https://img.shields.io/npm/dt/objection-authorize)](https://www.npmjs.com/package/objection-authorize) [![install size](https://packagephobia.now.sh/badge?p=objection-authorize)](https://packagephobia.now.sh/result?p=objection-authorize) [![David](https://img.shields.io/david/JaneJeon/objection-authorize)](https://david-dm.org/JaneJeon/objection-authorize) [![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) [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=JaneJeon/objection-authorize)](https://dependabot.com) [![License](https://img.shields.io/npm/l/objection-authorize)](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE) [![Docs](https://img.shields.io/badge/docs-github-blue)](https://janejeon.github.io/objection-authorize)
[![CircleCI](https://img.shields.io/circleci/build/github/JaneJeon/objection-authorize)](https://circleci.com/gh/JaneJeon/objection-authorize)
[![codecov](https://codecov.io/gh/JaneJeon/objection-authorize/branch/master/graph/badge.svg)](https://codecov.io/gh/JaneJeon/objection-authorize)
[![Maintainability](https://api.codeclimate.com/v1/badges/78bae22810143ad84ef1/maintainability)](https://codeclimate.com/github/JaneJeon/objection-authorize/maintainability)
[![NPM](https://img.shields.io/npm/v/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
[![Downloads](https://img.shields.io/npm/dt/objection-authorize)](https://www.npmjs.com/package/objection-authorize)
[![install size](https://packagephobia.now.sh/badge?p=objection-authorize)](https://packagephobia.now.sh/result?p=objection-authorize)
[![David](https://img.shields.io/david/JaneJeon/objection-authorize)](https://david-dm.org/JaneJeon/objection-authorize)
[![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)
[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=JaneJeon/objection-authorize)](https://dependabot.com)
[![License](https://img.shields.io/npm/l/objection-authorize)](https://github.com/JaneJeon/objection-authorize/blob/master/LICENSE)
[![Docs](https://img.shields.io/badge/docs-github-blue)](https://janejeon.github.io/objection-authorize)
[![Standard code style](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![Prettier code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)

> &#34;magical&#34; access control integrated with objection.js
Expand Down Expand Up @@ -30,6 +42,10 @@ yarn add objection-authorize # or
npm install objection-authorize --save
```

## Changelog

Starting from 1.0 release, all changes will be documented at the [releases page](https://github.com/JaneJeon/objection-authorize/releases).

## Usage

Plugging in `objection-authorize` to work with your existing authorization setup is as easy as follows:
Expand Down Expand Up @@ -179,8 +195,7 @@ Here's how it might work with express:

```js
app
// for this request, you might want to show the email only if the user is requesting itself,
// hence the authorization step
// for this request, you might want to show the email only if the user is requesting itself
.get('/users/:username', async (req, res) => {
const username = req.params.username.toLowerCase()
const user = await User.query()
Expand All @@ -190,7 +205,7 @@ app
res.send(user)
})
// for this request, you might want to only allow anonymous users to create an account,
// and filter its request body so that it doesn't write anything it's not supposed to (e.g. id/role)
// and prevent them from writing anything they're not allowed to (e.g. id/role)
.post('/users', async (req, res) => {
const user = await User.query()
.authorize(req.user, null, {
Expand All @@ -206,7 +221,7 @@ app
const username = req.params.username.toLowerCase()
// we fetch the user first to provide resource context for the authorize() call.
// Note that if we were to just call User.query().patchAndFetchById() and skip resource,
// then req.user would be able to modify any user before we can even authorize them!
// then the requester would be able to modify any user before we can even authorize them!
let user = await User.query().findOne({ username })
user = await user
.$query()
Expand Down
221 changes: 121 additions & 100 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const assert = require('http-assert')
const isEmpty = obj => !Object.keys(obj || {}).length

module.exports = (acl, opts) => {
if (!acl) throw new Error('acl is a required parameter!')
if (typeof acl.can !== 'function') {
if (typeof acl.can !== 'function')
throw new Error(
'did you pass the grants object directly instead of the access control instnace?'
)
}

const defaultOpts = {
defaultRole: 'anonymous',
Expand Down Expand Up @@ -50,15 +50,19 @@ module.exports = (acl, opts) => {

static get QueryBuilder () {
return class extends Model.QueryBuilder {
get _shouldCheckAccess () {
return this.context()._authorize
}

// wrappers around acl, querybuilder, and model
_checkAccess (action, body) {
const { user, resource, opts } = this.context()
async _checkAccess (action) {
let { user, resource, opts, body } = this.context()
body = body || resource

// _checkAccess may be called outside of authorization context
if (!(user && resource)) return
if (!this._shouldCheckAccess) return

const access = acl
const access = await acl
.can(user.role)
.execute(action)
.context(
Expand All @@ -82,120 +86,137 @@ module.exports = (acl, opts) => {
return access
}

// THE magic method that schedules the actual authorization logic to be called
// later down the line when the "action method" (insert/patch/delete) is called
authorize (user, resource, optOverride) {
user = Object.assign({ role: opts.defaultRole }, user)
resource = resource || this.context().instance || {}
const queryOpts = Object.assign({}, opts, optOverride)

return this.mergeContext({ user, resource, opts: queryOpts })
.runBefore((result, query) => {
// this is run AFTER the query has been completely built.
// In other words, the query already checked create/update/delete access
// by this point, and the only thing to check now is the read access,
// IF the resource is specified. Otherwise, it's delayed till the end!
if (query.isFind() && Object.keys(resource).length) {
const readAccess = query._checkAccess('read')

// store the read access just in case
query.mergeContext({ readAccess })
}

return result
})
.runAfter((result, query) => {
// there's no result object(s) to filter here
if (typeof result !== 'object') return result

const isArray = Array.isArray(result)

let {
resource,
first,
opts,
user,
readAccess
} = query.context()

// set the resource as the result if it's still not set!
// Note, since the resource needs to be singular, it can only be done
// when there's only one result!
if (!Object.keys(resource).length) {
if (!isArray) query.mergeContext({ resource: result })
else if (first) query.mergeContext({ resource: result[0] })
}

// after create/update operations, the returning result may be the requester
if (
(query.isInsert() || query.isUpdate()) &&
!isArray &&
opts.userFromResult
) {
// check if we the user is changed
const resultIsUser =
typeof opts.userFromResult === 'function'
? opts.userFromResult(user, result)
: true

// now we need to re-check read access from the context of the changed user
if (resultIsUser) {
// first, override the user and resource context for _checkAccess
query.mergeContext({ user: result })
// then obtain read access
readAccess = query._checkAccess('read')
}
}

readAccess = readAccess || query._checkAccess('read')

// if we're fetching multiple resources, the result will be an array.
// While access.filter() accepts arrays, we need to invoke any $formatJson()
// hooks by individually calling toJSON() on individual models since:
// 1. arrays don't have toJSON() method,
// 2. objection-visibility doesn't work without calling $formatJson()
return isArray
? result.map(model => model._filter(readAccess.attributes))
: result._filter(readAccess.attributes)
})
}

first () {
this.mergeContext({ first: true })

return super.first()
}

// insert/patch/update/delete are the "primitive" query actions.
// All other methods like insertAndFetch or deleteById are built on these.

// automatically checks if you can create this resource, and if yes,
// restricts the body object to only the fields they're allowed to set
// Because role-acl fucked up everything by making its APIs async,
// we CANNOT get the access context within these query methods,
// since they are synchronous.
// Therefore, the best we can do is to is to just augment the context,
// schedule the query to be run, and do the actual check in the runBefore() hook.
// However, this means that we CANNOT modify the body on-the-fly according
// to the access context, so you either pass the body directly, or you get 403 error.
insert (body) {
const access = this._checkAccess('create', body)
this.mergeContext({ body })

// when authorize() isn't called, access will be empty
return super.insert(access ? access.filter(body) : body)
return super.insert(body)
}

patch (body) {
const access = this._checkAccess('update', body)
this.mergeContext({ body })

return super.patch(access ? access.filter(body) : body)
return super.patch(body)
}

/* istanbul ignore next */
update (body) {
const access = this._checkAccess('update', body)
this.mergeContext({ body })

return super.update(access ? access.filter(body) : body)
return super.update(body)
}

delete (body) {
this._checkAccess('delete', body)
this.mergeContext({ body })

return super.delete()
}

deleteById (id, body) {
return this.findById(id).delete(body)
}

// THE magic method that schedules the actual authorization logic to be called
// later down the line when the query is built and is ready to be executed.
authorize (user, resource, optOverride) {
return (
this.mergeContext({
user: Object.assign({ role: opts.defaultRole }, user),
resource: resource || this.context().instance || {},
opts: Object.assign({}, opts, optOverride),
_authorize: true
})
// this is run AFTER the query has been completely built
.runBefore(async (result, query) => {
if (!query._shouldCheckAccess) return result

if (query.isInsert()) await query._checkAccess('create')
else if (query.isUpdate()) await query._checkAccess('update')
else if (query.isDelete()) await query._checkAccess('delete')
else if (
query.isFind() &&
// check read access if we know what the access is before the query is run,
// and then pass the readAccess to the context so we don't have to check again
!isEmpty(query.context().resource)
)
query.mergeContext({
readAccess: await query._checkAccess('read')
})

return result
})
.runAfter(async (result, query) => {
// there's no result object(s) to filter here
if (typeof result !== 'object' || !query._shouldCheckAccess)
return result

const isArray = Array.isArray(result)

let {
resource,
first,
opts,
user,
readAccess
} = query.context()

// set the resource as the result if it's still not set!
// Note, since the resource needs to be singular, it can only be done
// when there's only one result!
if (isEmpty(resource)) {
if (!isArray) query.mergeContext({ resource: result })
else if (first) query.mergeContext({ resource: result[0] })
}

// after create/update operations, the returning result may be the requester
if (
(query.isInsert() || query.isUpdate()) &&
!isArray &&
opts.userFromResult
) {
// check if we the user is changed
const resultIsUser =
typeof opts.userFromResult === 'function'
? opts.userFromResult(user, result)
: true

// now we need to re-check read access from the context of the changed user
if (resultIsUser) {
// first, override the user and resource context for _checkAccess
query.mergeContext({ user: result })
// then obtain read access
readAccess = query._checkAccess('read')
}
}

readAccess = readAccess || (await query._checkAccess('read'))

// if we're fetching multiple resources, the result will be an array.
// While access.filter() accepts arrays, we need to invoke any $formatJson()
// hooks by individually calling toJSON() on individual models since:
// 1. arrays don't have toJSON() method,
// 2. objection-visibility doesn't work without calling $formatJson()
return isArray
? result.map(model => model._filter(readAccess.attributes))
: result._filter(readAccess.attributes)
})
)
}

first () {
this.mergeContext({ first: true })

return super.first()
}
}
}
}
Expand Down

0 comments on commit 167cf46

Please sign in to comment.