Code Monkey home page Code Monkey logo

objection-authorize's Introduction

Welcome to objection-authorize 👋

CircleCI codecov NPM Downloads Docs

isomorphic, "magical" access control integrated with objection.js

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:

  • checking the user against the resource and the ACL
  • filtering request body according to the action and the user's access
  • figuring out which resource to check the user's grants against automatically(!)
  • even filtering the result from a query according to a user's read access!

Not sure why you would need this? Read below for examples or see here to learn just how complex access control can be and how you can manage said complexity with this plugin!

TL;DR:

Before:

class Post extends Model {}

app.put('/posts/:id', (req, res, next) => {
  // Need to handle random edge cases like the user not being signed in
  if (!req.user) next(new Error('must be signed in'))

  // Need to first fetch the post to know "can this user edit this post?"
  Post.query()
    .findById(req.params.id)
    .then(post => {
      if (req.user.id !== post.authorId || req.user.role !== 'editor')
        return next(new Error("Cannot edit someone else's post!"))

      // Prevent certain fields from being set after creation
      const postBody = omit(req.body, ['id', 'views', 'authorId'])

      // Prevent certain fields from being *changed*
      if (
        post.visibility === 'public' &&
        get(postBody, 'visibility') !== post.visibility &&
        req.user.role !== 'admin'
      )
        return next(
          new Error('Cannot take down a post without admin privileges!')
        )

      req.user
        .$relatedQuery('posts')
        .updateAndFetchById(post.id, postBody)
        .then(post => {
          // filter the resulting post based on user's access before sending it over
          if (req.user.role !== 'admin') post = omit(post, ['superSecretField'])

          res.send(post)
        })
        .catch(err => next(err))
    })
    .catch(err => next(err))
})

// And you need to repeat ALL of this validation on the frontend as well...

After:

// Use the plugin...
class Post extends require('objection-authorize')(acl, library, opts)(Model) {}

app.put('/posts/:id', (req, res, next) => {
  // ...and the ACL is automagically hooked in for ALL queries!
  Post.query()
    .updateAndFetchById(req.params.id, req.body)
    .authorize(req.user)
    .fetchResourceContextFromDB()
    .diffInputFromResource()
    .then(post => {
      res.send(post.authorizeRead(req.user))
    })
    .catch(err => next(err))
})

// AND you can re-use the ACL on the frontend as well *without* any changes!

Enjoy objection-authorize? Check out my other objection plugins: objection-hashid and objection-tablename!

Installation

To install the plugin itself:

yarn add objection-authorize # or
npm i objection-authorize --save

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.

Changelog

Starting from the 1.0 release, all changes will be documented at the releases page.

Terminology

A quick note, I use the following terms interchangeably:

  • resource and item(s) (both refer to model instance(s) that the query is fetching/modifying)
  • 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))

Usage

Plugging in objection-authorize to work with your existing authorization setup is as easy as follows:

const acl = ... // see below for defining acl

const { Model } = require('objection')
const authorize = require('objection-authorize')(acl, library[, opts])

class Post extends authorize(Model) {
  // That's it! This is just a regular objection.js model class
}

(Again, for now the only supported library value is casl)

Options

You can pass an optional options object as the third parameter during initialization. The default values are as follows:

const opts = {
  defaultRole: 'anonymous',
  unauthenticatedErrorCode: 401,
  unauthorizedErrorCode: 403,
  castDiffToModelClass: true,
  ignoreFields: [],
  casl: {
    useInputItemAsResourceForRelation: false
  }
}

For explanations on what each option does, see below:

defaultRole

When the user object is empty, a "default" user object will be created with the defaultRole (e.g. { role: opts.defaultRole }).

unauthenticatedErrorCode

Error code thrown when an unauthenticated user is not allowed to access a resource.

unauthorizedErrorCode

Error code thrown when an authenticated user is not allowed to access a resource.

castDiffToModelClass

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.

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.

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.

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.

ignoreFields

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.

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).

casl.useInputItemAsResourceForRelation

Normally, the item is used as "resource" since that's what the user is acting on.

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.

Methods

After initialization, the following "magic" methods are available for use:

QueryBuilder.authorize(user[, resource[, opts]])

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.

First, an explanation of the parameters:

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)!

The resource object is an optional parameter, and for most queries, you won't need to manually specify the resource.

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).

So, what are we actually checking here with this function?

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).

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.

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:

  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
  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.

That's it!

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?

The plugin looks at the following places to fetch the appropriate resource(s):

  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.
  2. If the resource parameter is not specified, then it looks at the model instance (if you're calling .$query() or .$relatedQuery())
  3. If you call .fetchContextFromDB(), then the plugin executes a pre-emptive SQL SELECT call to fetch the rows that the query would affect.

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].

That's it.

TIP: the .authorize() call can happen anywhere within the query chain!

QueryBuilder.action(action)

Rather than using the "default" actions (create/read/update/delete), you can override the action per query.

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").

QueryBuilder.inputItem(inputItem)

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.

QueryBuilder.fetchResourceContextFromDB()

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.

e.g.

await Person.query()
  .authorize(user)
  .where('lastName', 'george')
  .update({ lastName: 'George' }) // input item
  .fetchResourceContextFromDB() // Loads all people that would be affected by the update,
// and runs authorization check on *all* of those individuals against the input item.
QueryBuilder.diffInputFromResource()

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)!

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.

e.g.

Model.query()
  .authorize(user, { id: 1, foo: { bar: 'baz', a: 0 } })
  .updateById(id, { id: 1, foo: { bar: 'baz', b: 0 } })
  .diffInputFromResource() // the diff will be { foo: { b: 0 } }

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).

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.

e.g. in the above case, if you wanted to check whether field foo.a was deleted or not:

resource = { id: 1, foo: { bar: 'baz', a: 0, b: null } }
input = { id: 1, foo: { bar: 'baz', a: null, b: 0 } }
modelInstance.authorizeRead(user, [action = 'read'[, opts]])

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).

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.

Defining the ACL

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):

function acl(user, resource, action, body, opts) {
  // your ACL definition goes here
}

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.

For example, in a query:

await Person.relatedQuery('pets')
  .for([1, 2])
  .insert([{ name: 'doggo' }, { name: 'catto' }])
  .authorize(user)
  .fetchContextFromDB()

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! ;)

@casl/ability

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.

This is essentially the same as the defineAbilitiesFor(user) method described in the casl docs, but obviously with a lot more context.

So you might define your ability like this (and it doesn't matter if you use AbilityBuilder or Ability):

const { AbilityBuilder } = require('@casl/ability')

function acl(user, resource, action, body, opts) {
  return AbilityBuilder.define((allow, forbid) => {
    if (user.isAdmin()) {
      allow('manage', 'all')
    } else {
      allow('read', 'all')
    }
  })
}

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:

function acl(user, resource, action, body, opts) {
  return AbilityBuilder.define((allow, forbid) => {
    switch (resource.constructor.name) {
      case 'User':
        allow('read', 'User')
        forbid('read', 'User', ['email'])
        break
      case 'Post':
        allow('create', 'Post')
        forbid('read', 'Post', { private: true })
    }
  })
}

Note on Resource Names

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.

Note on Sharing the ACL between frontend and the backend

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.

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.

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).

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.

Relation support

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:

function acl(user, resource, action, body, opts, relation) {
  // your ACL definition goes here
}

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.

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.

Run tests

npm test

Author

👤 Jane Jeon

🤝 Contributing

Contributions, issues and feature requests are welcome!
Feel free to check issues page.

Show your support

Give a ⭐️ if this project helped you!

📝 License

Copyright © 2022 Jane Jeon.
This project is LGPL licensed.

TL;DR: you are free to import and use this library "as-is" in your code, without needing to make your code source-available or to license it under the same license as this library; however, if you do change this library and you distribute it (directly or as part of your code consuming this library), please do contribute back any improvements for this library and this library alone.

objection-authorize's People

Contributors

dependabot-preview[bot] avatar dependabot[bot] avatar janejeon avatar renovate-bot avatar renovate[bot] avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

objection-authorize's Issues

[DepShield] (CVSS 7.5) Vulnerability due to usage of debug:2.6.9

Vulnerabilities

DepShield reports that this application's usage of debug:2.6.9 results in the following vulnerability(s):


Occurrences

debug:2.6.9 is a transitive dependency introduced by the following direct dependency(s):

jest:24.9.0
        └─ jest-cli:24.9.0
              └─ @jest/core:24.9.0
                    └─ micromatch:3.1.10
                          └─ extglob:2.0.4
                                └─ expand-brackets:2.1.4
                                      └─ debug:2.6.9
                          └─ snapdragon:0.8.2
                                └─ debug:2.6.9

standard:14.3.1
        └─ eslint-plugin-import:2.18.2
              └─ eslint-import-resolver-node:0.3.2
                    └─ debug:2.6.9
              └─ eslint-module-utils:2.4.1
                    └─ debug:2.6.9
              └─ debug:2.6.9

This is an automated GitHub Issue created by Sonatype DepShield. Details on managing GitHub Apps, including DepShield, are available for personal and organization accounts. Please submit questions or feedback about DepShield to the Sonatype DepShield Community.

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

circleci
.circleci/config.yml
  • node 5
  • slack 4
  • codecov 3
  • cimg/postgres 14.4
docker-compose
docker-compose.yml
  • postgres 14-alpine
github-actions
.github/workflows/codeql-analysis.yml
  • actions/checkout v3
  • github/codeql-action v2
  • github/codeql-action v2
  • github/codeql-action v2
.github/workflows/ossar-analysis.yml
  • actions/checkout v3
  • github/ossar-action v1
  • github/codeql-action v2
npm
package.json
  • http-errors ^2.0.0
  • lodash ^4.17.21
  • @casl/ability ^6.0.0
  • @janejeon/eslint-config ^2.2.0
  • @janejeon/prettier-config ^1.1.0
  • dotenv ^16.0.0
  • jest ^28.0.0
  • jest-junit ^14.0.0
  • knex ^2.0.0
  • lint-staged 13.0.3
  • node-notifier ^10.0.0
  • npm-run-all ^4.1.5
  • objection ^3
  • objection-table-name ^2.0.0
  • pg ^8.7.1
  • skip-ci ^1.0.4
  • objection ^3

  • Check this box to trigger a request for Renovate to run again on this repository

How to avoid `Cannot read property 'id' of undefined`

Hey, thanks for the great plugin. I was wondering how to avoid an error like Cannot read property 'id' of undefined when creating a call like this:

const posts = Post.query()
  .authorize(req.user)
  .where('user_id', req.user.id)

If a user is not logged in, there is no user id, hence the error. Is there a way to throw the auth error before evaluating the where statement, or how would you handle this?

Support filtering fields for PATCH/update operations

Currently, when you're doing patch requests, the entire req.body is passed to the update method, which causes the ACL to think that you're actually touching all the fields.

That is basically useless for the purpose of checking the ACL, so we really should filter out all of the unchanged fields for update()s.
(Besides, if you were doing any sort of update/patch operations, you always wanted to fetch the resource beforehand anyway so this isn't an extra DB call. Perhaps we can utilize toFindQuery in the static hooks? #73)

Reduce deep cloning in RBAC

https://github.com/tensult/role-acl/blob/76b3637ca7cfa4bf7ed26fd8a90da16f048005f0/src/utils/common.ts#L348
https://github.com/onury/notation/blob/6202846489d3f9adba3665cd447c9947199d6bb4/src/core/notation.js#L520

A lot of stuff needs to be deep cloned every time we want to do any form of access control, and considering that req.body can get pretty big, we might want to reduce the sheer amount of objects we need to deep clone, which feels like a helluva unnecessary thing.

This might also be solved via JaneJeon/express-objection-starter#54 if we go with the fastify approach, because we can limit the incoming body via schemas!

Automatically inject id context when using findById

A common pattern is this:

result = await User.query()
          .authorize(testUser, { id: testUser.id }) // specify resource
          .findById(testUser.id)

but we're specifying the id twice - can we pull that information from findById (using the id identifiers) and possibly other where conditions?

Refactor the whole context mess as its own managed class/type

...since the context about the context is NOT passed in-between methods currently (so you have no idea what fields ARE available on the context, and what type they are!)

Right now everything's held together with fucking duct tape of random functions EVERYWHERE

Receiving Error: Cannot find module './adapters/role-acl when attempting to add authorize to one of my models

I hope this message finds you well. In trying to implement "const authorize = require('objection-authorize')(acl, library[, opts])" from the Usage section of your repository, I am stalled due to a missing dependency in the objection-authorize directory. It looks like node is trying to find it and it appears to be missing.
Screen Shot 2022-01-29 at 10 27 43 PM
Screen Shot 2022-01-29 at 10 29 01 PM
I'm very much liking the project and hope this can be resolved. In my project, I added the file directly from the role-acl repository but only to see if the issue would be fixed in any way. Thank you for your time.

Condition in allow function always return false

ACL:

const {defineAbility} = require('@casl/ability');

function acl(user, resource, action, body, opts, relation){
    

    console.log(user,resource,action,body,relation);

    return defineAbility((allow,forbid)=>{

        allow('read','UserProfile'); // works without condition

        allow('update','UserProfile',{user_id:user.id});      // error caught: ForbiddenError: Forbidden

    });
}

module.exports = acl;

User Model:

const {Model} = require('objection');
const Password = require('objection-password')();

class User extends Password(Model){

    static get tableName(){
        return 'user';
    }

    static get idColumn(){
        return 'id';
    }

    static get jsonSchema(){
        return {
            type:'object',
            required:['username','email','password',],
            properties:{
                email:{type:'string'},
                username:{type:'string',minLength: 1, maxLength: 255},
                password:{type:'string',minLength: 1, maxLength: 255},
                role:{type:'string'}
            }
        }
    }


    static get relationMappings(){
        
        const UserProfile = require('./UserProfile');
        const UserAddress = require('./UserAddress');

        return{

            userProfile:{
                relation:Model.HasOneRelation,
                modelClass:UserProfile,
                join:{
                    from:'user.id',
                    to:'user_profile.user_id'
                }
            },

            userAddress:{
                relation:Model.HasManyRelation,
                modelClass:UserAddress,
                join:{
                    from:'user.id',
                    to:'user_address.user_id'
                }
            }

        }
    }
}

module.exports = User;

User Profile model:

const BaseModel = require('./BaseModel');

class UserProfile extends BaseModel{ 


    static get idColumn(){
        return 'id';
    }

    fullName(){
        return this.first_name+ " " +this.last_name;
    }

    static get jsonSchema(){
        return {
            type:'object',
            required:['user_id','birthday','phone_number'],
            properties:{
                user_id:{type:'integer'},
                birthday:{type:'string',format:'date-time'},
                phone_number:{type:'string',minLength: 1, maxLength: 255},
            }
        }
    }

    static get relationMappings(){

        const User = require('./User');

        return {
            user:{
                relation:BaseModel.BelongsToOneRelation,
                modelClass:User,
                join:{
                    from:'user_profile.user_id',
                    to:'user.id'
                }
            }
        }
    }


}

module.exports = UserProfile;

Update user profile:

 await UserProfile.query().findById(1).patch( {
                                                    birthday:new Date().toISOString(),
                                                    first_name:'newName' 
                                                    }).authorize(req.user).then(result=>res.send(`result ${result}`)).catch(error=>res.send(`error ${error}`));

User Profile Table:
image

User Table:
image

Console.log user parameter in acl function:

{
  role: 'admin',
  id: 1,
  username: 'user1',
  email: 'email1',
  password: 'password1',
  email_verified_at: 2021-04-26T18:09:53.000Z,
  created_at: 2021-04-26T18:09:53.000Z,
  updated_at: 2021-04-26T18:09:53.000Z
} 

Query is not aborted when an error is thrown

Sometimes, when an error occurs from inside this plugin (e.g. authorization fails, or something goes wrong internally) while it's running within express, it hangs the request!

This is weird, because on the tests that expect this plugin to fail, it throws immediately, but the same does not happen from within express... wtf I can't wrap my head around this AHHHHH

Drop support for objection@1

  1. I'm tired of seeing the deprecation message
  2. New typings (esp. w/ #1) may be incompatible with the v1 typings
  3. Dropping v1 support may let us use new features like static hooks. In particular, I'm interested in this: Unlike the instance query hooks, static hooks are executed once per query. Static hooks are always executed and there are no corner cases like the $beforeDelete/$afterDelete issue with instance hooks. http://vincit.github.io/objection.js/guide/hooks.html#static-query-hooks

What is the library parameter during initialization?

const acl = ... // see below for defining acl
const { Model } = require('objection')
const authorize = require('objection-authorize')(acl, library[, opts])

class Post extends authorize(Model) {
// That's it! This is just a regular objection.js model class
}

does the library parameter mean instance of CASL?

ACL relation parameter is undefined

ACL function:

const {defineAbility} = require('@casl/ability');

function acl(user, resource, action, body, opts, relation){
    

    console.log(user,resource,action,body,relation);     // relation is undefined

    return defineAbility((allow,forbid)=>{

        allow('read','UserProfile');


        if(relation === 'userProfile')
            allow('update','UserProfile');

    });
}

module.exports = acl;

Base Model:

const { Model } = require('objection')
const TableNamer = require('objection-table-name');
const {snakeCase} = require('change-case');

class BaseModel extends TableNamer({
                                    caseMapper:snakeCase
                                  })(Model) 
                                  {
                                    
  static get modelPaths() {
    return [__dirname];
  }

  static get useLimitInFirst() {
    return true;
  }

  static get defaultEagerAlgorithm() {
    return Model.JoinEagerAlgorithm;
  }
}

module.exports = BaseModel;

User Model:

const {Model} = require('objection');
const Password = require('objection-password')();

class User extends Password(Model){

    static get tableName(){
        return 'user';
    }

    static get idColumn(){
        return 'id';
    }

    static get jsonSchema(){
        return {
            type:'object',
            required:['username','email','password',],
            properties:{
                email:{type:'string'},
                username:{type:'string',minLength: 1, maxLength: 255},
                password:{type:'string',minLength: 1, maxLength: 255},
                role:{type:'string'}
            }
        }
    }


    static get relationMappings(){
        
        const UserProfile = require('./UserProfile');
        const UserAddress = require('./UserAddress');

        return{

            userProfile:{
                relation:Model.HasOneRelation,
                modelClass:UserProfile,
                join:{
                    from:'user.id',
                    to:'user_profile.user_id'
                }
            },

            userAddress:{
                relation:Model.HasManyRelation,
                modelClass:UserAddress,
                join:{
                    from:'user.id',
                    to:'user_address.user_id'
                }
            }

        }
    }
}

module.exports = User;

UserProfile Model:

const BaseModel = require('./BaseModel');

class UserProfile extends BaseModel{ 


    static get idColumn(){
        return 'id';
    }

    fullName(){
        return this.first_name+ " " +this.last_name;
    }

    static get jsonSchema(){
        return {
            type:'object',
            required:['user_id','birthday','phone_number'],
            properties:{
                user_id:{type:'integer'},
                birthday:{type:'string',format:'date-time'},
                phone_number:{type:'string',minLength: 1, maxLength: 255},
            }
        }
    }

    static get relationMappings(){

        const User = require('./User');

        return {
            user:{
                relation:BaseModel.BelongsToOneRelation,
                modelClass:User,
                join:{
                    from:'user_profile.user_id',
                    to:'user.id'
                }
            }
        }
    }


}

module.exports = UserProfile;

update user profile:

async function update(req,res){

   // error caught: ForbiddenError: Forbidden
    await UserProfile.query().findById(1).patch( {
                                                    birthday:new Date().toISOString(),
                                                    first_name:'newName' 
                                                    }).authorize(req.user).then(result=>res.send(`result ${result}`)).catch(error=>res.send(`error ${error}`));

}

normal relation query is working fine:

const userProfile = await UserProfile.query().findById(1);

const user = await userProfile.$relatedQuery('user');

res.send(user);

   // {
    //     "id": 1,
    //     "username": "user1",
    //     "email": "email1",
    //     "password": "password1",
    //     "role": "admin",
    //     "email_verified_at": "2021-04-26T18:09:53.000Z",
    //     "created_at": "2021-04-26T18:09:53.000Z",
    //     "updated_at": "2021-04-26T18:09:53.000Z"
    // }

Cache adapters?

Somewhat related to #7, basically can I cache the resource and filter them instead of having to go to the database?

Should we wrap the resource parameter with a model class?

Both the req.user and the resource are wrapped in Objection model classes quite often, especially when the resource is auto-inferred.

So should we wrap the resource paramter (.authorize(user, resource)) when it's passed manually as a POJO?

This could be useful if you expect both the user and the resource to have identical transformations (e.g. id => hashid)...

Add casl support in favour of role-acl

Role-acl's move to asynchronous everything basically makes body filtering impossible.
And given that versions <4.0 will not be maintained, we need to move to something that is synchronous and actively maintained. Right now I'm thinking casl.
Have a separate 1.X branch where you can continue to use role-acl, but from 2.0 on, require casl.

BUG: detecting deletion of nested JSON fields from input

Currently, when we "diff" the input JSON (A) with the input item JSON (B), we only include fields and nested fields from B that are NOT in A.

This detects all nested field changes in B that might not be present in A, except for when you delete a field in A that is straight up not in B.

And considering that the objectDiff was meant to be used in UPDATE queries, I'm not sure what to do in case of A's field being removed by B...

Typescript support - would you consider moving entirely to typescript?

I was working with the module with the latest version of objection and hit some problems and errors. I decided to convert it to typescript and got it working. Would you consider moving to typescript? The changes look trivial to do so - see below.

import httpError from 'http-errors'
import pick  from 'lodash.pick'
import omit from 'lodash.omit'
import { Model } from "objection"

const isEmpty = ( obj:object) => !Object.keys(obj || {}).length

class AuthorizationQueryBuilder<M extends Model, R = M[]> extends Model.QueryBuilder<M, R> {
	get _shouldCheckAccess () {
        return this.context()._authorize
      }

      // Wrap the resource to give it all the custom methods & properties
      // defined in the associating model class (e.g. Post, User).
      set _resource (_resource) {
        // Wrap the resource only if it's not an instance of a model already.
        // Rather than checking if the resource is instance of the Model base class,
        // we are simply checking that the resource has a $query property.
        if (!_resource || !_resource.$query)
          _resource = this.modelClass().fromJson(_resource, {
            skipValidation: true
          })
        this.mergeContext({ _resource })
      }

	  // wrappers around acl, querybuilder, and model
      _checkAccess (action, body) {
        if (!this._shouldCheckAccess) return body

        const {
          _user: user,
          _resource: resource,
          _opts: opts,
          _action
        } = this.context()
        // allowed the specified action to override the default, inferred action
        action = _action || action

        const access = lib.getAccess(acl, user, resource, action, body, opts)

        // authorize request
        if (!lib.isAuthorized(access, action, resource))
          throw httpError(
            user.role === opts.defaultRole
              ? opts.unauthenticatedErrorCode
              : opts.unauthorizedErrorCode
          )

        return access
      }

      // convenience helper for insert/update/delete
      _filterBody (action, body) {
        if (!this._shouldCheckAccess) return body

        const access = this._checkAccess(action, body)
        const { _resource: resource } = this.context()

        // there's no need to cache these fields because this isn't the read access.
        const pickFields = lib.pickFields(access, action, resource)
        const omitFields = lib.omitFields(access, action, resource)

        if (pickFields.length) body = pick(body, pickFields)
        if (omitFields.length) body = omit(body, omitFields)

        return body
      }

      // 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.
      insert (body) {
        return super.insert(this._filterBody('create', body))
      }

      insertAndFetch (body) {
        return super.insertAndFetch(this._filterBody('create', body))
      }

      patch (body) {
        return super.patch(this._filterBody('update', body))
      }

      patchAndFetch (body) {
        return super.patchAndFetch(this._filterBody('update', body))
      }

      // istanbul ignore next
      patchAndFetchById (id, body) {
        return super.patchAndFetchById(id, this._filterBody('update', body))
      }

      // istanbul ignore next
      update (body) {
        return super.update(this._filterBody('update', body))
      }

      // istanbul ignore next
      updateAndFetch (body) {
        return super.updateAndFetch(this._filterBody('update', body))
      }

      // istanbul ignore next
      updateAndFetchById (id, body) {
        return super.updateAndFetchById(id, this._filterBody('update', body))
      }

      delete (body) {
        this._checkAccess('delete', body)
        return super.delete()
      }

      // istanbul ignore next
      deleteById (id, body) {
        this._checkAccess('delete', body)
        return super.deleteById(id)
      }

      // specify a custom action, which takes precedence over the "default" action.
      action (_action) {
        this.mergeContext({ _action })
        return this
      }

      // result is always an array, so we figure out if we should look at the result
      // as a single object instead by looking at whether .first() was called or not.
      first () {
        this.mergeContext({ _first: true })
        return super.first()
      }

      // 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) {
        resource = resource || this.context()._instance || {}
        this._resource = resource
        this.mergeContext({
          _user: Object.assign({ role: opts.defaultRole }, user),
          _opts: Object.assign({}, opts, optOverride),
          _authorize: true
        })
          // 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, we check the read access after the query has been run, on the
          // query results as the resource.
          .runBefore(async (result, query) => {
            if (query.isFind() && !isEmpty(resource)) {
              const readAccess = query._checkAccess('read')

              // store the read access so that it can be reused after the query.
              query.mergeContext({ readAccess })
            }

            return result
          })
          .runAfter(async (result, query) => {
            // If there's no result objects, we don't need to filter them.
            if (typeof result !== 'object' || !query._shouldCheckAccess)
              return result

            const isArray = Array.isArray(result)

            let {
              _resource: resource,
              _first: first,
              _opts: opts,
              _user: user,
              _readAccess: 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 -
            // we're trusting that if the query returns an array of results,
            // then you've already filtered it according to the user's read access
            // in the query (instead of relying on the ACL to do it) since it's costly
            // to check every single item in the result array...
            if (isEmpty(resource) && (!isArray || first)) {
              resource = isArray ? result[0] : result
              resource = query.modelClass().fromJson(resource, {
                skipValidation: true
              })
              query.mergeContext({ _resource: resource })
            }

            // after create/update operations, the returning result may be the requester
            if (
              (query.isInsert() || query.isUpdate()) &&
              !isArray &&
              opts.userFromResult
            ) {
              // check if 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._filterModel(readAccess))
              : result._filterModel(readAccess)
          })

        // for chaining
        return this
      }
}

export default function (acl, library = 'role-acl', opts) { 
	if (!acl) throw new Error('acl is a required parameter!')
	if (typeof library === 'object') {
		throw new Error(
		  'objection-authorize@3 now has the signature (acl, library, opts)'
		)
	}

	const defaultOpts = {
		defaultRole: 'anonymous',
		unauthenticatedErrorCode: 401,
		unauthorizedErrorCode: 403,
		userFromResult: false,
		// below are role-acl specific options
		contextKey: 'req',
		roleFromUser: user => user.role,
		resourceAugments: { true: true, false: false, undefined: undefined }
	}
	opts = Object.assign(defaultOpts, opts)

	const lib = require(`./lib/${library}`)

	return <M extends typeof Model>(ModelClass: typeof Model): M => {
	  return class extends ModelClass {
		  // filter the model instance directly
	      _filterModel (readAccess) {
	        const pickFields = lib.pickFields(readAccess, 'read', this)
	        const omitFields = lib.omitFields(readAccess, 'read', this)

	        if (pickFields.length) this.$pick(pickFields)
	        if (omitFields.length) this.$omit(omitFields)

	        return this // for chaining
	      }

	      // inject instance context
	      $query (trx) {
	        return super.$query(trx).mergeContext({ _instance: this })
	      }

	      $relatedQuery (relation, trx) {
	        return super
	          .$relatedQuery(relation, trx)
	          .mergeContext({ _instance: this })
	      }


		  QueryBuilderType!: AuthorizationQueryBuilder<this>
		  static QueryBuilder = AuthorizationQueryBuilder

	  } as unknown as M
	}
}

Relation support is undocumented or missing

According to the readme the library should support applying ACL to related models (experimentally? is there an option?).

Looking at the authorizeRead function on the Model extension (master branch) – it appears to simply be applying rules to the Model instance itself and not traversing relationships.

PS. Thank you for creating this project – let me know if you need another contributor!

Add support for role-acl@>4.3.2

They added synchronous methods back in tensult/role-acl#34, so I'd like to add support for whatever versions support the .sync() method.

Note that any versions between 4 and 4.3.2 are still NOT supported due to the lack of .sync() method.

  • Add a way to dynamically determine if we're using <4 or >4.3.2
  • throw errors on 4~4.3.2
  • Modify access check method based on the version
  • Add tests for different versions...? how?

Add json-rule-engine support as an alternative to CASL

For this we might have to expand the definition of "authorization library", but I know for a fact that there exist "rule engines" that can effectively be used for the same purpose, with the additional benefit of being able to see/build/debug the rules visually in the UI, without having to write test cases which are a pain in the ass.

For starters, I know for a fact that https://github.com/cachecontrol/json-rules-engine can be used to do "if this then that", with the only caveat being that you need to configure "then that" part to tell the objection-authorize plugin to say "DON'T ALLOW THIS", but this is where the pluggable interface comes in!

Plus, it has a nice UI you can use: https://github.com/vinzdeveloper/json-rule-editor

Is it possible to retrieve and use permission defined in DB?

If I were to allow permissions to be changed dynamically via DB tables, is it possible to do it one shot in .authorize() ?

I can think of querying making another query in the controller before the one with .authorize().

Could you give me an example if you have better solution?

[DepShield] (CVSS 7.4) Vulnerability due to usage of lodash.sortby:4.7.0

Vulnerabilities

DepShield reports that this application's usage of lodash.sortby:4.7.0 results in the following vulnerability(s):


Occurrences

lodash.sortby:4.7.0 is a transitive dependency introduced by the following direct dependency(s):

jest:24.9.0
        └─ jest-cli:24.9.0
              └─ jest-config:24.9.0
                    └─ jest-environment-jsdom:24.9.0
                          └─ jsdom:11.12.0
                                └─ data-urls:1.1.0
                                      └─ whatwg-url:7.0.0
                                            └─ lodash.sortby:4.7.0
                                └─ whatwg-url:6.5.0
                                      └─ lodash.sortby:4.7.0

This is an automated GitHub Issue created by Sonatype DepShield. Details on managing GitHub Apps, including DepShield, are available for personal and organization accounts. Please submit questions or feedback about DepShield to the Sonatype DepShield Community.

Filter the SQL query rather than the output from it

Currently, we implement field-level access control by hooking into the query builder's output and filtering the resulting model instances.

However, we could prevent fetching data we don't need (i.e. the attributes that we've filtered out manually using JS) from the database by using something like this:

Of course, we would need to translate the whole ['*', '!email'] into an actual list of columns.

As for why bother with this, besides the obvious (fetching less data means less I/O for the database`), it is because I gave up on caching manually:

Obviously, with the way RBAC is set up, we can't do request-level caching (since the form of the resource requested may be different depending on who's requesting it).

And even if some resources aren't returned differently based on who's requesting it, that level of caching would be done better at the reverse proxy level (think Cloudflare), since the request needs to travel less (physically) before hitting a cache.

So for the resources that are requester-dependent, we need to cache them at the resource level or below. A query returns a resource, and if we filter the output after it has been fetched from the database, then the resource looks the same no matter who requests it (since we didn't filter it yet). That means we can cache it on Redis! Woohoo

Except caching is difficult. It's so fucking difficult I don't want to deal with it myself. LRUs are wasteful, and if we need to hit Redis for the cache, it's 1. yet another mandatory hit to Redis per request, and 2. going to need to travel outside of the database.

Databases already maintain really complicated caches of their own, and what's more, all of this is done transparently, within the same node that is going to actually process the database query, so even if the cache misses, it's not that big of a deal.

So if we rely on the database for caching reads, then we need to reduce the load on the database as much as possible - and that means reducing I/O. Furthermore, now we don't need to have the same "shape" for each resource since we're not filtering it manually from node.js.

So I don't know, on the one hand, it would reduce database I/O, but on the other hand, it would make resource-level caching impossible.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.