UNPKG

@resin/pinejs

Version:

Pine.js is a sophisticated rules-driven API engine that enables you to define rules in a structured subset of English. Those rules are used in order for Pine.js to generate a database schema and the associated [OData](http://www.odata.org/) API. This make

691 lines (625 loc) • 23.5 kB
_ = require 'lodash' Promise = require 'bluebird' env = require '../config-loader/env' userModel = require './user.sbvr' { metadataEndpoints, memoizedParseOdata } = require './uri-parser' { BadRequestError, PermissionError, PermissionParsingError } = require './errors' { ODataParser } = require '@resin/odata-parser' memoize = require 'memoizee' memoizeWeak = require 'memoizee/weak' { sqlNameToODataName } = require '@resin/odata-to-abstract-sql' DEFAULT_ACTOR_BIND = '@__ACTOR_ID' DEFAULT_ACTOR_BIND_REGEX = new RegExp(_.escapeRegExp(DEFAULT_ACTOR_BIND), 'g') exports.PermissionError = PermissionError exports.PermissionParsingError = PermissionParsingError exports.root = user: permissions: [ 'resource.all' ] exports.rootRead = rootRead = user: permissions: [ 'resource.get' ] methodPermissions = GET: or: ['get', 'read'] PUT: or: [ 'set' and: ['create', 'update'] ] POST: or: ['set', 'create'] PATCH: or: ['set', 'update'] MERGE: or: ['set', 'update'] DELETE: 'delete' _parsePermissions = do -> odataParser = ODataParser.createInstance() return memoize( (filter) -> # Reset binds odataParser.binds = [] tree = odataParser.matchAll(['FilterByExpression', filter], 'ProcessRule') return { tree extraBinds: odataParser.binds } primitive: true max: env.cache.parsePermissions.max ) rewriteBinds = ({ tree, extraBinds }, odataBinds) -> # Add the extra binds we parsed onto our existing list of binds vars. bindsLength = odataBinds.length odataBinds.push(extraBinds...) # Clone the tree so the cached version can't be mutated and at the same time fix the bind numbers return _.cloneDeepWith tree, (value) -> bind = value?.bind if _.isInteger(bind) return { bind: value.bind + bindsLength } parsePermissions = (filter, odataBinds) -> odata = _parsePermissions(filter) rewriteBinds(odata, odataBinds) # Traverses all values in `check`, actions for the following data types: # string: Calls `stringCallback` and uses the value returned instead # boolean: Used as-is # array: Treated as an AND of all elements # object: Must have only one key of either `AND` or `OR`, with an array value that will be treated according to the key. exports.nestedCheck = nestedCheck = (check, stringCallback) -> if _.isString(check) stringCallback(check) else if _.isBoolean(check) return check else if _.isArray(check) results = [] for subcheck in check result = nestedCheck(subcheck, stringCallback) if result is false return false else if result isnt true results = results.concat(result) if results.length is 1 return results[0] else if results.length > 1 return _.uniq(results) else return true else if _.isObject(check) checkTypes = _.keys(check) if checkTypes.length > 1 throw new Error('More than one check type: ' + checkTypes) checkType = checkTypes[0] switch checkType.toUpperCase() when 'AND' return nestedCheck(check[checkType], stringCallback) when 'OR' results = [] for subcheck in check[checkType] result = nestedCheck(subcheck, stringCallback) if result is true return true else if result isnt false results = results.concat(result) if results.length is 1 return results[0] else if results.length > 1 return _.uniq(results) else return false else throw new Error('Cannot parse required checking logic: ' + checkType) else throw new Error('Cannot parse required checks: ' + check) collapsePermissionFilters = (v) -> if _.isArray(v) collapsePermissionFilters(or: v) else if _.isObject(v) if v.hasOwnProperty('filter') v.filter else _(v) .toPairs() .flattenDeep() .map(collapsePermissionFilters) .value() else v addRelationshipBypasses = (relationships) -> _.each relationships, (relationship, key) -> return if key is '$' mapping = relationship.$ if mapping? and mapping.length is 2 mapping = _.cloneDeep(mapping) mapping[1][0] = "#{mapping[1][0]}$bypass" relationships["#{key}$bypass"] = { $: mapping } addRelationshipBypasses(relationship) getPermissionsLookup = memoize( (permissions) -> permissionsLookup = {} for permission in permissions [ target, condition ] = permission.split('?') if !condition? # We have unconditional permission permissionsLookup[target] = true else if permissionsLookup[target] != true permissionsLookup[target] ?= [] permissionsLookup[target].push(condition) return permissionsLookup primitive: true max: env.cache.permissionsLookup.max ) checkPermissions = (permissionsLookup, actionList, vocabulary, resourceName) -> checkObject = or: ['all', actionList] return nestedCheck checkObject, (permissionCheck) -> resourcePermission = permissionsLookup['resource.' + permissionCheck] if resourcePermission is true return true if vocabulary? vocabularyPermission = permissionsLookup[vocabulary + '.' + permissionCheck] if vocabularyPermission is true return true if resourceName? vocabularyResourcePermission = permissionsLookup[vocabulary + '.' + resourceName + '.' + permissionCheck] if vocabularyResourcePermission is true return true conditionalPermissions = [].concat(resourcePermission, vocabularyPermission, vocabularyResourcePermission) # Remove the undefined elements. conditionalPermissions = _.filter(conditionalPermissions) if conditionalPermissions.length is 1 return conditionalPermissions[0] else if conditionalPermissions.length > 1 return or: conditionalPermissions return false generateConstrainedAbstractSql = (permissionsLookup, actionList, vocabulary, resourceName) -> uriParser = require('./uri-parser') conditionalPerms = checkPermissions(permissionsLookup, actionList, vocabulary, resourceName) if conditionalPerms is false throw new PermissionError() if conditionalPerms is true # If we have full access then no need to provide a constrained definition return false odata = memoizedParseOdata("/#{resourceName}") permissionFilters = nestedCheck conditionalPerms, (permissionCheck) -> try permissionCheck = parsePermissions(permissionCheck, odata.binds) # We use an object with filter key to avoid collapsing our filters later. return filter: permissionCheck catch e console.warn('Failed to parse conditional permissions: ', permissionCheck) throw new PermissionParsingError(e) permissionFilters = collapsePermissionFilters(permissionFilters) _.set(odata, [ 'tree', 'options', '$filter' ], permissionFilters) { odataBinds, abstractSqlQuery } = uriParser.translateUri({ method: 'GET', resourceName, vocabulary, odataBinds: odata.binds, odataQuery: odata.tree, values: {} }) abstractSqlQuery = _.clone(abstractSqlQuery) # Remove aliases from the top level select selectIndex = _.findIndex(abstractSqlQuery, 0: 'Select') select = abstractSqlQuery[selectIndex] = _.clone(abstractSqlQuery[selectIndex]) select[1] = _.map select[1], (selectField) -> if selectField.length is 2 and _.isArray(selectField[0]) return selectField[0] return selectField return { extraBinds: odataBinds, abstractSqlQuery } # Call the function once and either return the same result or throw the same error on subsequent calls onceGetter = (obj, propName, fn) -> thrownErr = undefined Object.defineProperty obj, propName, enumerable: true configurable: true get: -> if thrownErr? throw thrownErr try result = fn() fn = undefined delete this[propName] this[propName] = result catch thrownErr throw thrownErr deepFreezeExceptDefinition = (obj) -> Object.freeze(obj) Object.getOwnPropertyNames(obj).forEach (prop) -> # We skip the definition because we know it's a property we've defined that will throw an error in some cases if prop isnt 'definition' and obj.hasOwnProperty(prop) and obj[prop] isnt null and (typeof obj[prop] not in [ 'object', 'function' ]) deepFreezeExceptDefinition(obj) return memoizedGetConstrainedModel = memoizeWeak( (abstractSqlModel, permissionsLookup, vocabulary) -> abstractSqlModel = _.cloneDeep(abstractSqlModel) addRelationshipBypasses(abstractSqlModel.relationships) _.each abstractSqlModel.synonyms, (canonicalForm, synonym) -> abstractSqlModel.synonyms["#{synonym}$bypass"] = "#{canonicalForm}$bypass" addRelationshipBypasses(abstractSqlModel.relationships) _.each abstractSqlModel.relationships, (relationship, key) -> abstractSqlModel.relationships["#{key}$bypass"] = relationship _.each abstractSqlModel.tables, (table) -> abstractSqlModel.tables["#{table.resourceName}$bypass"] = _.clone(table) onceGetter table, 'definition', -> # For $filter on eg a DELETE you need read permissions on the sub-resources, # you only need delete permissions on the resource being deleted generateConstrainedAbstractSql(permissionsLookup, methodPermissions.GET, vocabulary, sqlNameToODataName(table.name)) deepFreezeExceptDefinition(abstractSqlModel) return abstractSqlModel normalizer: (abstractSqlModel, args) -> return JSON.stringify(args) ) exports.config = models: [ apiRoot: 'Auth' modelText: userModel customServerCode: exports ] exports.setup = (app, sbvrUtils) -> sbvrUtils.addPureHook 'all', 'all', 'all', PREPARSE: ({ req }) -> apiKeyMiddleware(req) POSTPARSE: ({ req, request }) -> # If the abstract sql query is already generated then adding permissions will do nothing return if request.abstractSqlQuery? if (request.method == 'POST' and request.odataQuery.property?.resource == 'canAccess') if !request.odataQuery.key? throw new BadRequestError() { action, method } = request.values if method? and action? throw new BadRequestError() else if method? and methodPermissions[method]? request.permissionType = methodPermissions[method] else if action? request.permissionType = action else throw new BadRequestError() abstractSqlModel = sbvrUtils.getAbstractSqlModel(request) request.resourceName = request.resourceName.slice(0, -'#canAccess'.length) resourceName = sbvrUtils.resolveSynonym(request) resourceTable = abstractSqlModel.tables[resourceName] if !resourceTable? throw new Error('Unknown resource: ' + request.resourceName) idField = resourceTable.idField request.odataQuery.options = { '$select': { 'properties': [ { 'name': idField } ] }, $top: 1 } request.odataQuery.resource = request.resourceName delete request.odataQuery.property request.method = 'GET' request.custom.isAction = 'canAccess' addPermissions(req, request) PRERESPOND: ({ request, data }) -> if (request.custom.isAction == 'canAccess') if (_.isEmpty(data)) # If the caller does not have any permissions to access the # resource pine will throw a PermissionError. To have the # same behavior for the case that the user has permissions # to access the resource, but not this instance we also # throw a PermissionError if the result is empty. throw new PermissionError() sbvrUtils.addPureHook 'POST', 'Auth', 'user', POSTPARSE: ({ request, api }) -> api.post resource: 'actor' options: { returnResource: false } .then (result) -> request.values.actor = result.id sbvrUtils.addPureHook 'DELETE', 'Auth', 'user', POSTRUN: ({ request, api }) -> api.delete resource: 'actor' id: request.values.actor exports.checkPassword = (username, password, callback) -> authApi = sbvrUtils.api.Auth authApi.get resource: 'user' passthrough: req: rootRead options: $select: ['id', 'actor', 'password'] $filter: username: username .then (result) -> if result.length is 0 throw new Error('User not found') hash = result[0].password userId = result[0].id actorId = result[0].actor sbvrUtils.sbvrTypes.Hashed.compare(password, hash) .then (res) -> if !res throw new Error('Passwords do not match') getUserPermissions(userId) .then (permissions) -> return { id: userId actor: actorId username: username permissions: permissions } .nodeify(callback) getPermissions = (permsFilter, callback) -> authApi = sbvrUtils.api.Auth authApi.get resource: 'permission' passthrough: req: rootRead options: $select: 'name' $filter: permsFilter # We orderby to increase the hit rate for the `_checkPermissions` memoisation $orderby: name: 'asc' .map (permission) -> permission.name .tapCatch (err) -> authApi.logger.error('Error loading permissions', err, err.stack) .nodeify(callback) exports.getUserPermissions = getUserPermissions = (userId, callback) -> if _.isString(userId) userId = _.parseInt(userId) if !_.isFinite(userId) return Promise.rejected(new Error('User ID has to be numeric, got: ' + typeof userId)) permsFilter = $or: is_of__user: $any: $alias: 'uhp' $expr: uhp: user: userId $or: [ uhp: expiry_date: null , uhp: expiry_date: $gt: $now: null ] is_of__role: $any: $alias: 'rhp' $expr: rhp: role: $any: $alias: 'r' $expr: r: is_of__user: $any: $alias: 'uhr' $expr: uhr: user: userId $or: [ uhr: expiry_date: null , uhr: expiry_date: $gt: $now: null ] return getPermissions(permsFilter, callback) exports.getApiKeyPermissions = getApiKeyPermissions = do -> _getApiKeyPermissions = memoize( (apiKey) -> permsFilter = $or: is_of__api_key: $any: $alias: 'khp' $expr: khp: api_key: $any: $alias: 'k' $expr: k: key: apiKey is_of__role: $any: $alias: 'rhp' $expr: 'rhp': role: $any: $alias: 'r' $expr: r: is_of__api_key: $any: $alias: 'khr' $expr: khr: api_key: $any: $alias: 'k' $expr: k: key: apiKey return getPermissions(permsFilter) primitive: true max: env.cache.apiKeys.max maxAge: env.cache.apiKeys.maxAge ) return (apiKey, callback) -> promise = if _.isString(apiKey) _getApiKeyPermissions(apiKey) else Promise.rejected(new Error('API key has to be a string, got: ' + typeof apiKey)) return promise.nodeify(callback) getApiKeyActorId = memoize( (apiKey) -> sbvrUtils.api.Auth.get resource: 'api_key' passthrough: req: rootRead options: $select: 'is_of__actor' $filter: key: apiKey .then (apiKeys) -> if apiKeys.length is 0 throw new Error('Could not find the api key') apiKeyActorID = apiKeys[0].is_of__actor.__id if !apiKeyActorID? throw new Error('API key is not linked to a actor?!') return apiKeyActorID primitive: true promise: true maxAge: env.cache.apiKeys.maxAge ) checkApiKey = (req, apiKey) -> Promise.try -> if !apiKey? or req.apiKey? return getApiKeyPermissions(apiKey) .catch (err) -> console.warn('Error with API key:', err) # Ignore errors getting the api key and just use an empty permissions object return [] .then (permissions) -> req.apiKey = key: apiKey permissions: permissions exports.customAuthorizationMiddleware = customAuthorizationMiddleware = (expectedScheme = 'Bearer') -> expectedScheme = expectedScheme.toLowerCase() return (req, res, next) -> Promise.try -> auth = req.header('Authorization') if !auth return parts = auth.split(' ') if parts.length isnt 2 return [ scheme, apiKey ] = parts if scheme.toLowerCase() isnt expectedScheme return checkApiKey(req, apiKey) .then -> next?() return # A default bearer middleware for convenience exports.authorizationMiddleware = customAuthorizationMiddleware() exports.customApiKeyMiddleware = customApiKeyMiddleware = (paramName = 'apikey') -> return (req, res, next) -> apiKey = req.params[paramName] ? req.body[paramName] ? req.query[paramName] checkApiKey(req, apiKey) .then -> next?() return # A default api key middleware for convenience exports.apiKeyMiddleware = apiKeyMiddleware = customApiKeyMiddleware() exports.checkPermissions = (req, actionList, resourceName, vocabulary) -> getReqPermissions(req) .then (permissionsLookup) -> checkPermissions(permissionsLookup, actionList, vocabulary, resourceName) exports.checkPermissionsMiddleware = (action) -> return (req, res, next) -> exports.checkPermissions(req, action) .then (allowed) -> switch allowed when false res.sendStatus(401) when true next() else throw new Error('checkPermissionsMiddleware returned a conditional permission') .catch (err) -> sbvrUtils.api.Auth.logger.error('Error checking permissions', err, err.stack) res.sendStatus(503) getReqPermissions = do -> _getGuestPermissions = do -> # Start the guest permissions as null, having it as a reject promise either # causes an issue with an unhandled rejection, or with enabling long stack traces. _guestPermissions = null return -> if !_guestPermissions? or _guestPermissions.isRejected() # Get guest user _guestPermissions = sbvrUtils.api.Auth.get resource: 'user' passthrough: req: rootRead options: $select: 'id' $filter: username: 'guest' .then (result) -> if result.length is 0 throw new Error('No guest permissions') getUserPermissions(result[0].id) return _guestPermissions return (req, odataBinds = {}) -> Promise.join( _getGuestPermissions() Promise.try -> if req.apiKey?.permissions?.length > 0 getApiKeyActorId(req.apiKey.key) (guestPermissions, apiKeyActorID) -> if _.some(guestPermissions, (p) -> DEFAULT_ACTOR_BIND_REGEX.test(p)) throw new Error('Guest permissions cannot reference actors') permissions = guestPermissions actorIndex = 0 addActorPermissions = (actorId, actorPermissions) -> actorBind = DEFAULT_ACTOR_BIND if actorIndex > 0 actorBind += actorIndex actorPermissions = _.map actorPermissions, (actorPermission) -> actorPermission.replace(DEFAULT_ACTOR_BIND_REGEX, actorBind) odataBinds[actorBind] = [ 'Real', actorId ] actorIndex++ permissions = permissions.concat(actorPermissions) if req.user?.permissions? addActorPermissions(req.user.actor, req.user.permissions) if req.apiKey?.permissions? addActorPermissions(apiKeyActorID, req.apiKey.permissions) permissions = _.uniq(permissions) return getPermissionsLookup(permissions) ) resolveSubRequest = (request, lambda, propertyName, v) -> if !lambda[propertyName]? v.name = "#{propertyName}$bypass" newResourceName = lambda[propertyName] ? sbvrUtils.resolveNavigationResource(request, propertyName) return { abstractSqlModel: request.abstractSqlModel vocabulary: request.vocabulary resourceName: newResourceName } memoizedRewriteODataOptions = do -> rewriteODataOptions = (request, data, lambda = {}) -> _.each data, (v, k) -> if _.isArray(v) rewriteODataOptions(request, v, lambda) else if _.isObject(v) propertyName = v.name if propertyName? if v.lambda? newLambda = _.clone(lambda) newLambda[v.lambda.identifier] = sbvrUtils.resolveNavigationResource(request, propertyName) # TODO: This should actually use the top level resource context, # however odata-to-abstract-sql is bugged so we use the lambda context to match that bug for now subRequest = resolveSubRequest(request, lambda, propertyName, v) rewriteODataOptions(subRequest, v, newLambda) else if v.options? _.each v.options, (option, optionName) -> subRequest = resolveSubRequest(request, lambda, propertyName, v) rewriteODataOptions(subRequest, option, lambda) else if v.property? subRequest = resolveSubRequest(request, lambda, propertyName, v) rewriteODataOptions(subRequest, v, lambda) else rewriteODataOptions(request, v, lambda) else rewriteODataOptions(request, v, lambda) return memoizeWeak( (abstractSqlModel, vocabulary, resourceName, filter, tree) -> tree = _.cloneDeep(tree) rewriteODataOptions({ abstractSqlModel, vocabulary, resourceName }, [tree]) return tree normalizer: (abstractSqlModel, [ vocabulary, resourceName, filter ]) -> filter + vocabulary + resourceName ) parseRewrittenPermissions = (abstractSqlModel, vocabulary, resourceName, filter, odataBinds) -> { tree, extraBinds } = _parsePermissions(filter) tree = memoizedRewriteODataOptions(abstractSqlModel, vocabulary, resourceName, filter, tree) return rewriteBinds({ tree, extraBinds }, odataBinds) addODataPermissions = (permissionsLookup, permissionType, vocabulary, resourceName, odataQuery, odataBinds, abstractSqlModel) -> conditionalPerms = checkPermissions(permissionsLookup, permissionType, vocabulary, resourceName) if conditionalPerms is false throw new PermissionError() if conditionalPerms isnt true permissionFilters = nestedCheck conditionalPerms, (permissionCheck) -> try permissionCheck = parseRewrittenPermissions(abstractSqlModel, vocabulary, resourceName, permissionCheck, odataBinds) # We use an object with filter key to avoid collapsing our filters later. return filter: permissionCheck catch e console.warn('Failed to parse conditional permissions: ', permissionCheck) throw new PermissionParsingError(e) if permissionFilters is false throw new PermissionError() if permissionFilters isnt true permissionFilters = collapsePermissionFilters(permissionFilters) odataQuery.options ?= {} if odataQuery.options.$filter? odataQuery.options.$filter = ['and', odataQuery.options.$filter, permissionFilters] else odataQuery.options.$filter = permissionFilters exports.addPermissions = addPermissions = (req, request) -> { method, vocabulary, resourceName, permissionType, odataQuery, odataBinds } = request abstractSqlModel = sbvrUtils.getAbstractSqlModel(request) method = method.toUpperCase() isMetadataEndpoint = resourceName in metadataEndpoints or method is 'OPTIONS' permissionType ?= if isMetadataEndpoint 'model' else if methodPermissions[method]? methodPermissions[method] else console.warn('Unknown method for permissions type check: ', method) 'all' # This bypasses in the root cases, needed for fetching guest permissions to work, it can almost certainly be done better though permissions = (req.user?.permissions || []).concat(req.apiKey?.permissions || []) if permissions.length > 0 and checkPermissions(getPermissionsLookup(permissions), permissionType, vocabulary) is true # We have unconditional permission to access the vocab so there's no need to intercept anything return getReqPermissions(req, odataBinds) .then (permissionsLookup) -> # Update the request's abstract sql model to use the constrained version request.abstractSqlModel = abstractSqlModel = memoizedGetConstrainedModel(abstractSqlModel, permissionsLookup, vocabulary) if !_.isEqual(permissionType, methodPermissions.GET) sqlName = sbvrUtils.resolveSynonym(request) odataQuery.resource = "#{sqlName}$bypass" addODataPermissions(permissionsLookup, permissionType, vocabulary, resourceName, odataQuery, odataBinds, abstractSqlModel)