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

Typescript support - would you consider moving entirely to typescript? #130

Closed
panigrah opened this issue Jun 25, 2020 · 3 comments
Closed
Labels
enhancement New feature or request

Comments

@panigrah
Copy link

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
	}
}
@issue-label-bot
Copy link

Issue-Label Bot is automatically applying the label enhancement to this issue, with a confidence of 0.66. Please mark this comment with 👍 or 👎 to give our bot feedback!

Links: app homepage, dashboard and code for this bot.

@issue-label-bot issue-label-bot bot added the enhancement New feature or request label Jun 25, 2020
@JaneJeon
Copy link
Owner

Hey, thanks for the issue. Somebody already made a PR #79 so you could take a look at that to see if you're on the right track. Unfortunately I'm currently REALLY occupied with work at the moment so if you could make a PR with the tests running I'd be happy to take a look at it. Thanks

@JaneJeon
Copy link
Owner

See JaneJeon/objection-hashid#46 for why actually correctly typing Objection plugins is, unfortunately, impossible at the current state of Objection/TypeScript :(

Feel free to re-open/create a PR if, at any point, Objection’s Model.query() typing starts working again :)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants