From d590b3c6c62d67fd6a651f7ac0f35737e2082425 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Mon, 9 Sep 2024 17:47:57 +0300 Subject: [PATCH] implement when condition (#153) * implement when condition * set query expressions for nested queries (#156) * implement when condition * handle privileges for many-to-many associations * try select an attribute of an associated object * validate nested object attributes * 2.6.51 --- data-attribute-resolver.js | 193 +++++-- data-object-tag.d.ts | 3 +- data-object.d.ts | 5 +- data-permission.js | 180 ++++--- data-queryable.js | 20 +- data-ref-object-listener.js | 3 +- has-parent-junction.d.ts | 1 + jest.setup.js | 2 + package-lock.json | 4 +- package.json | 2 +- spec/DataModelFilterParser.spec.ts | 98 ++++ spec/DataPrivileges.spec.ts | 23 +- spec/GlobalPrivileges.spec.ts | 509 ++++++++++++++++++ spec/ParentPrivileges.spec.ts | 90 ++++ spec/test2/config/models/Order.json | 7 + spec/test2/config/models/OrderAction.json | 24 +- spec/test2/config/models/Product.json | 29 + .../test2/config/models/ProductDimension.json | 16 + spec/test2/config/models/SpecialOffer.json | 17 + types.d.ts | 38 ++ types.js | 3 + 21 files changed, 1134 insertions(+), 133 deletions(-) create mode 100644 spec/DataModelFilterParser.spec.ts create mode 100644 spec/GlobalPrivileges.spec.ts create mode 100644 spec/ParentPrivileges.spec.ts diff --git a/data-attribute-resolver.js b/data-attribute-resolver.js index 9850134..40df689 100644 --- a/data-attribute-resolver.js +++ b/data-attribute-resolver.js @@ -1,38 +1,35 @@ +var {QueryField, QueryEntity, QueryUtils} = require('@themost/query'); var {sprintf} = require('sprintf-js'); -var Symbol = require('symbol'); var _ = require('lodash'); var {DataError} = require('@themost/common'); -var {QueryField} = require('@themost/query'); -var {QueryEntity} = require('@themost/query'); -var {QueryUtils} = require('@themost/query'); -var aliasProperty = Symbol('alias'); +var Symbol = require('symbol'); var {hasOwnProperty} = require('./has-own-property'); - - +var aliasProperty = Symbol('alias'); /** * @class + * @constructor */ function DataAttributeResolver() { } DataAttributeResolver.prototype.orderByNestedAttribute = function(attr) { - var nestedAttribute = DataAttributeResolver.prototype.testNestedAttribute(attr); + var nestedAttribute = new DataAttributeResolver().testNestedAttribute(attr); if (nestedAttribute) { var matches = /^(\w+)\((\w+)\/(\w+)\)$/i.exec(nestedAttribute.name); if (matches) { - return DataAttributeResolver.prototype.selectAggregatedAttribute.call(this, matches[1], matches[2] + '/' + matches[3]); + return new DataAttributeResolver().selectAggregatedAttribute.call(this, matches[1], matches[2] + '/' + matches[3]); } matches = /^(\w+)\((\w+)\/(\w+)\/(\w+)\)$/i.exec(nestedAttribute.name); if (matches) { - return DataAttributeResolver.prototype.selectAggregatedAttribute.call(this, matches[1], matches[2] + '/' + matches[3] + '/' + matches[4]); + return new DataAttributeResolver().selectAggregatedAttribute.call(this, matches[1], matches[2] + '/' + matches[3] + '/' + matches[4]); } } - return DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr); + return new DataAttributeResolver().resolveNestedAttribute.call(this, attr); }; -DataAttributeResolver.prototype.selecteNestedAttribute = function(attr, alias) { - var expr = DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr); +DataAttributeResolver.prototype.selectNestedAttribute = function(attr, alias) { + var expr = new DataAttributeResolver().resolveNestedAttribute.call(this, attr); if (expr) { if (_.isNil(alias)) expr.as(attr.replace(/\//g,'_')); @@ -49,8 +46,8 @@ DataAttributeResolver.prototype.selecteNestedAttribute = function(attr, alias) { */ DataAttributeResolver.prototype.selectAggregatedAttribute = function(aggregation, attribute, alias) { var self=this, result; - if (DataAttributeResolver.prototype.testNestedAttribute(attribute)) { - result = DataAttributeResolver.prototype.selecteNestedAttribute.call(self,attribute, alias); + if (new DataAttributeResolver().testNestedAttribute(attribute)) { + result = new DataAttributeResolver().selectNestedAttribute.call(self,attribute, alias); } else { result = self.fieldOf(attribute); @@ -77,11 +74,13 @@ DataAttributeResolver.prototype.resolveNestedAttribute = function(attr) { //description: Support many to many (junction) resolving var mapping = self.model.inferMapping(member[0]); if (mapping && mapping.associationType === 'junction') { - var expr1 = DataAttributeResolver.prototype.resolveJunctionAttributeJoin.call(self.model, attr); + var expr1 = new DataAttributeResolver().resolveJunctionAttributeJoin.call(self.model, attr); //select field select = expr1.$select; //get expand - expr = expr1.$expand; + expr = { + $expand: expr1.$expand + }; } else { // create member expression @@ -89,7 +88,7 @@ DataAttributeResolver.prototype.resolveNestedAttribute = function(attr) { name: attr }; // and pass member expression - expr = DataAttributeResolver.prototype.resolveNestedAttributeJoin.call(self.model, memberExpr); + expr = new DataAttributeResolver().resolveNestedAttributeJoin.call(self.model, memberExpr); //select field if (member.length>2) { if (memberExpr.name !== attr) { @@ -103,25 +102,37 @@ DataAttributeResolver.prototype.resolveNestedAttribute = function(attr) { // get member segments again because they have been modified member = memberExpr.name.split('/'); } - // and create query field expression - select = QueryField.select(member[1]).from(member[0]); + // if attribute has been resolved by the previous attribute resolver (for join) + // use the returned value which is also a query field expression + // + // important note: this operation is very important in cases where + // we are trying to select or filter zero-or-one associated items that are + // defined by an association of type junction (a typical many-to-many association) + // e.g. $select=orderedItem/madeId as madeInCountry&$filter=orderedItem/madeId ne null + // where Product.madeId is a zero-or-one associated country with a product + if (expr && expr.$select) { + select = expr.$select; + } else { + // otherwise build query field expression + select = QueryField.select(member[1]).from(member[0]); + } } } - if (expr) { + if (expr && expr.$expand) { if (_.isNil(self.query.$expand)) { - self.query.$expand = expr; + self.query.$expand = expr.$expand; } else { arr = []; - if (!_.isArray(self.query.$expand)) { + if (!Array.isArray(self.query.$expand)) { arr.push(self.query.$expand); this.query.$expand = arr; } arr = []; - if (_.isArray(expr)) - arr.push.apply(arr, expr); + if (Array.isArray(expr.$expand)) + arr.push.apply(arr, expr.$expand); else - arr.push(expr); + arr.push(expr.$expand); arr.forEach(function(y) { obj = self.query.$expand.find(function(x) { if (x.$entity && x.$entity.$as) { @@ -146,7 +157,7 @@ DataAttributeResolver.prototype.resolveNestedAttribute = function(attr) { /** * * @param {*} memberExpr - A string that represents a member expression e.g. user/id or article/published etc. - * @returns {*} - An object that represents a query join expression + * @returns {{$select?:QueryField,$expand?:{QueryEntity}[]}} - An object that represents a query join expression */ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr) { var self = this, childField, parentField, res, expr, entity; @@ -187,7 +198,7 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr var childFieldName = childField.property || childField.name; /** * store temp query expression - * @type QueryExpression + * @type {import('@themost/query').QueryExpression} */ res =QueryUtils.query(self.viewAdapter).select(['*']); expr = QueryUtils.query().where(QueryField.select(childField.name) @@ -203,11 +214,31 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr }); if (arrMember.length>2) { parentModel[aliasProperty] = mapping.childField; - expr = DataAttributeResolver.prototype.resolveNestedAttributeJoin.call(parentModel, arrMember.slice(1).join('/')); - return [].concat(res.$expand).concat(expr); + expr = new DataAttributeResolver().resolveNestedAttributeJoin.call(parentModel, arrMember.slice(1).join('/')); + return { + $select: expr.$select, + $expand: [].concat(res.$expand).concat(expr.$expand) + }; + } else if (arrMember.length === 2) { + // try to find if the nested member is an association of type junction + var nestedMember = arrMember[1]; + /** + * @type {import("./types").DataAssociationMapping} + */ + var nestedMapping = parentModel.inferMapping(nestedMember); + if (nestedMapping && nestedMapping.associationType === 'junction') { + // resolve nested member + parentModel[aliasProperty] = mapping.childField; + expr = new DataAttributeResolver().resolveJunctionAttributeJoin.call(parentModel, nestedMember); + return { + $select: expr.$select, + $expand: [].concat(res.$expand).concat(expr.$expand) + }; + } } - //--set active field - return res.$expand; + return { + $expand: res.$expand + }; } else if (mapping.parentModel===self.name && mapping.associationType==='association') { var childModel = self.context.model(mapping.childModel); @@ -240,9 +271,12 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr // set joined entity alias childModel[aliasProperty] = childEntity; // resolve additional joins - expr = DataAttributeResolver.prototype.resolveNestedAttributeJoin.call(childModel, arrMember.slice(1).join('/')); + expr = new DataAttributeResolver().resolveNestedAttributeJoin.call(childModel, arrMember.slice(1).join('/')); // concat and return joins - return [].concat(res.$expand).concat(expr); + return { + $select: expr.$select, + $expand: [].concat(res.$expand).concat(expr.$expand) + }; } else { // get child model member var childMember = childModel.field(arrMember[1]); @@ -257,10 +291,16 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr } } } - return res.$expand; + return { + $expand: res.$expand + }; } else { - throw new Error(sprintf('The association type between %s and %s model is not supported for filtering, grouping or sorting data.', mapping.parentModel , mapping.childModel)); + if (mapping.associationType === 'junction' && mapping.parentModel === self.name) { + return new DataAttributeResolver().resolveJunctionAttributeJoin.call(self, memberExpr); + } else { + throw new Error(sprintf('The association type between %s and %s model is not supported for filtering, grouping or sorting data.', mapping.parentModel , mapping.childModel)); + } } } }; @@ -271,7 +311,7 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr */ DataAttributeResolver.prototype.testAttribute = function(s) { if (typeof s !== 'string') - return; + return null; /** * @private */ @@ -312,7 +352,7 @@ DataAttributeResolver.prototype.testAttribute = function(s) { */ DataAttributeResolver.prototype.testAggregatedNestedAttribute = function(s) { if (typeof s !== 'string') - return; + return null; /** * @private */ @@ -357,7 +397,7 @@ DataAttributeResolver.prototype.testAggregatedNestedAttribute = function(s) { */ DataAttributeResolver.prototype.testNestedAttribute = function(s) { if (typeof s !== 'string') - return; + return null; /** * @private */ @@ -465,7 +505,7 @@ DataAttributeResolver.prototype.testNestedAttribute = function(s) { /** * @param {string} attr - * @returns {*} + * @returns {{$select?:QueryField,$expand?:{QueryEntity}[]}} */ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { var self = this, member = attr.split('/'); @@ -475,52 +515,72 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { if (mapping && mapping.associationType === 'junction') { //get field var field = self.field(member[0]), entity, expr, q; + var thisAlias = self[aliasProperty] || self.viewAdapter; //first approach (default association adapter) //the underlying model is the parent model e.g. Group > Group Members if (mapping.parentModel === self.name) { - + var associationAlias = mapping.associationAdapter; q =QueryUtils.query(self.viewAdapter).select(['*']); //init an entity based on association adapter (e.g. GroupMembers as members) - entity = new QueryEntity(mapping.associationAdapter).as(field.name); + entity = new QueryEntity(mapping.associationAdapter).as(associationAlias); + if (field.multiplicity === 'ZeroOrOne') { + entity.$join = 'left'; + } + Object.defineProperty(entity, 'model', { + configurable: true, + enumerable: false, + writable: true, + value: mapping.associationAdapter + }); //init join expression between association adapter and current data model //e.g. Group.id = GroupMembers.parent - expr = QueryUtils.query().where(QueryField.select(mapping.parentField).from(self.viewAdapter)) - .equal(QueryField.select(mapping.associationObjectField).from(field.name)); + expr = QueryUtils.query().where(QueryField.select(mapping.parentField).from(thisAlias)) + .equal(QueryField.select(mapping.associationObjectField).from(associationAlias)); //append join q.join(entity).with(expr); //data object tagging if (typeof mapping.childModel === 'undefined') { return { $expand:[q.$expand], - $select:QueryField.select(mapping.associationValueField).from(field.name) + $select:QueryField.select(mapping.associationValueField).from(associationAlias) } } - //return the resolved attribute for futher processing e.g. members.id - if (member[1] === mapping.childField) { - return { - $expand:[q.$expand], - $select:QueryField.select(mapping.associationValueField).from(field.name) - } - } - else { + //return the resolved attribute for further processing e.g. members.id + // if (member[1] === mapping.childField) { + // return { + // $expand:[q.$expand], + // $select:QueryField.select(mapping.associationValueField).from(associationAlias) + // } + // } + // else { //get child model var childModel = self.context.model(mapping.childModel); if (_.isNil(childModel)) { throw new DataError('EJUNC','The associated model cannot be found.'); } //create new join - var alias = field.name + '_' + childModel.name; + var alias = field.name; // + '_' + childModel.name; entity = new QueryEntity(childModel.viewAdapter).as(alias); - expr = QueryUtils.query().where(QueryField.select(mapping.associationValueField).from(field.name)) + if (field.multiplicity === 'ZeroOrOne') { + entity.$join = 'left'; + } + // set model + Object.defineProperty(entity, 'model', { + configurable: true, + enumerable: false, + writable: true, + value: childModel.name + }); + expr = QueryUtils.query().where(QueryField.select(mapping.associationValueField).from(associationAlias)) .equal(QueryField.select(mapping.childField).from(alias)); //append join q.join(entity).with(expr); return { $expand:q.$expand, - $select:QueryField.select(member[1]).from(alias) + $select:QueryField.select(member[1] || mapping.childField).from(alias) } - } + //} } else { q =QueryUtils.query(self.viewAdapter).select(['*']); @@ -564,7 +624,24 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { throw new DataError('EJUNC','The target model does not have a many to many association defined by the given attribute.','', self.name, attr); } }; +/** + * @this + * @param {*} attr + */ +DataAttributeResolver.prototype.resolveZeroOrOneNestedAttribute = function(attr) { + var self = this; + var fullyQualifiedMember = attr.split('/'); + var index = 0; + var currentModel = self.model; + while (index < fullyQualifiedMember.length) { + var member = fullyQualifiedMember[index]; + var attribute = currentModel.getAttribute(member); + if (attribute.multiplicity !== 'ZeroOrOne') { + // do nothing + } + } +} module.exports = { DataAttributeResolver -} \ No newline at end of file +} diff --git a/data-object-tag.d.ts b/data-object-tag.d.ts index bfeedc9..1c2e033 100644 --- a/data-object-tag.d.ts +++ b/data-object-tag.d.ts @@ -5,6 +5,7 @@ import {DataModel} from "./data-model"; import {DataObject} from "./data-object"; export declare class DataObjectTag extends DataQueryable { + constructor(target: any, association: DataAssociationMapping | string); parent: DataObject; mapping: DataAssociationMapping; getBaseModel(): DataModel; @@ -13,5 +14,5 @@ export declare class DataObjectTag extends DataQueryable { insert(obj: any): Promise; remove(obj: any): Promise; removeAll(): Promise; - migrate(callback: (err?: Error) => void); + migrate(callback: (err?: Error) => void): void; } diff --git a/data-object.d.ts b/data-object.d.ts index 75721cf..beb669b 100644 --- a/data-object.d.ts +++ b/data-object.d.ts @@ -17,6 +17,7 @@ export declare class DataObject extends SequentialEventEmitter { getModel(): DataModel; getAdditionalModel():Promise; getAdditionalObject():Promise; - attr(name: string, callback?:(err?: Error,res?: any) => void); - property(name: string); + attr(name: string, callback?:(err?: Error,res?: any) => void): void; + attr(name: string): any; + property(name: string): any; } diff --git a/data-permission.js b/data-permission.js index 21a82f0..64df0f0 100644 --- a/data-permission.js +++ b/data-permission.js @@ -2,7 +2,7 @@ var {QueryEntity} = require('@themost/query'); var {QueryUtils} = require('@themost/query'); var async = require('async'); -var {AccessDeniedError} = require('@themost/common'); +var {AccessDeniedError, DataError} = require('@themost/common'); var {DataConfigurationStrategy} = require('./data-configuration'); var _ = require('lodash'); var { at } = require('lodash') @@ -10,6 +10,8 @@ var {DataCacheStrategy} = require('./data-cache'); var Q = require('q'); var {hasOwnProperty} = require('./has-own-property'); var {DataModelFilterParser} = require('./data-model-filter.parser'); +var {DataQueryable} = require('./data-queryable'); +var {SelectObjectQuery} = require('./select-object-query'); /** * @class @@ -520,6 +522,7 @@ DataPermissionEventListener.prototype.validate = function(event, callback) { }); } else if (item.type==='self') { + // #implementWhenExpression // check if the specified privilege has account attribute if (typeof item.account !== 'undefined' && item.account !== null && item.account !== '*') { // if user does not have this account return @@ -528,55 +531,35 @@ DataPermissionEventListener.prototype.validate = function(event, callback) { } } if (requestMask===PermissionMask.Create) { - var query = QueryUtils.query(model.viewAdapter); - var fields=[], field; - //cast target - var name, obj = event.target; - model.attributes.forEach(function(x) { - name = typeof x.property === 'string' ? x.property : x.name; - if (hasOwnProperty(obj, name)) - { - var mapping = model.inferMapping(name); - if (_.isNil(mapping)) { - field = {}; - field[x.name] = { $value: obj[name] }; - fields.push(field); - } - else if ((mapping.associationType==='association') && (mapping.childModel===model.name)) { - if (typeof obj[name] === 'object' && obj[name] !== null) { - //set associated key value (event.g. primary key value) - field = {}; - field[x.name] = { $value: obj[name][mapping.parentField] }; - fields.push(field); - } - else { - //set raw value - field = {}; - field[x.name] = { $value: obj[name] }; - fields.push(field); - } - } - } - }); - //add fields - query.select(fields); - //set fixed query - query.$fixed = true; - model.filter(item.filter, function(err, q) { + var query = new SelectObjectQuery(model).select(event.target); + const filter = item.when || item.filter; + model.filter(filter, function(err, q) { if (err) { cb(err); } else { - //set where from DataQueryable.query - query.$where = q.query.$prepared; - query.$expand = q.query.$expand; + // get filter params (where and join statements) + var {$where, $prepared, $expand} = q.query; + if ($where === null && $prepared === null) { + return cb(new Error('Where condition cannot be empty while validating object privileges.')); + } + // and assign them to the fixed query produced by the previous step + Object.assign(query, { + $where, + $prepared, + $expand + }); + // execute query model.context.db.execute(query,null, function(err, result) { if (err) { return cb(err); } else { - if (result.length===1) { + // if user has access + if (result.length === 1) { + // set cancel flag for exiting the loop cancel=true; + // set result to true event.result = true; } return cb(); @@ -586,24 +569,79 @@ DataPermissionEventListener.prototype.validate = function(event, callback) { }); } else { - //get privilege filter - model.filter(item.filter, function(err, q) { + // get primary key + var { [model.primaryKey]: key } = event.target; + // get privilege filter + var parser = new DataModelFilterParser(model); + // stage 1: parse filter condition + // the "when" statement is a filter condition that should be evaluated before validating the current state of the object + var when = item.when || item.filter; + parser.parse(when, function(err, params) { if (err) { return cb(err); } - else { - //prepare query and append primary key expression - q.where(model.primaryKey).equal(event.target[model.primaryKey]).silent().count(function(err, count) { - if (err) { cb(err); return; } - if (count>=1) { - cancel=true; - event.result = true; + var { $where, $expand } = params; + if ($where === null) { + return cb(new Error('Where condition cannot be empty while validating object privileges.')); + } + var q = new DataQueryable(model); + Object.assign(q.query, { + $where, + $expand + }); + // stage 2: query for object and get original data + return q.where(model.primaryKey).equal(key).silent().flatten().getItems().then( + function(results) { + // throw error if more than one result is returned + if (results.length > 1) { + return cb(new DataError('E_PRIMARY_KEY', 'Primary key violation', null, model.name, model.primaryKey)); + } + if (results.length === 1) { + // get result + var [result] = results; + // get target object ready for validation + var selectTarget = new SelectObjectQuery(model).map(event.target); + var target = requestMask === PermissionMask.Update ? Object.assign(result, selectTarget) : result; + // get filter condition which is going to be evaluated against the target object + var filter = item.filter || item.when; + return parser.parse(filter, function(err, params) { + if (err) { + return cb(err); + } + var query = new SelectObjectQuery(model).select(target); + // get filter params (where and join statements) + var {$where, $expand} = params; + // and assign them to the fixed query produced by the previous step + // note: a fixed query is a query that contains constant values + // and is being prepared for validating filter conditions defined by the current privilege + Object.assign(query, { + $where, + $expand + }); + // execute native query + return model.context.db.execute(query,null, function(err, result) { + if (err) { + return cb(err); + } + if (result.length === 1) { + // user has access + // set cancel flag for exiting the loop + cancel=true; + // set result to true + event.result = true; + } + return cb(); + }); + }); } return cb(); - }) - } + } + ).catch(function(err) { + return cb(err); + }); }); } + // #implementWhenExpression } else { //do nothing (unknown permission) @@ -938,21 +976,39 @@ DataPermissionEventListener.prototype.beforeExecute = function(event, callback) }); } else if (item.type==='parent') { - //get field mapping + // #implementWhenExpression + // is this privilege assignable to the current user -and its groups-? + if (typeof item.account !== 'undefined' && item.account !== null && item.account !== '*') { + if (accounts.findIndex(function(x) { return x.name === item.account; }) < 0) { + return cb(); + } + } + // try to get mapping from "property" which should be an attribute of the current model var mapping = model.inferMapping(item.property); - if (!mapping) { - return cb(); + if (mapping == null) { + // if mapping is not found, throw error + return cb(new DataError('Invalid configuration. A parent privilege should refer to an attribute which defines an association.', null, model.name, item.property)); } - if (_.isNil(expr)) + if (expr == null) { expr = QueryUtils.query(); - expr.where(entity.select(mapping.childField)).equal(perms1.select('target')). - and(perms1.select('privilege')).equal(mapping.childModel). - and(perms1.select('parentPrivilege')).equal(mapping.parentModel). - and(perms1.select('workspace')).equal(workspace). - and(perms1.select('mask')).bit(requestMask,requestMask). - and(perms1.select('account')).in(accounts.map(function(x) { return x.id; })).prepare(true); + } + // + if (mapping.childModel !== model.name) { + return cb(new DataError('Invalid configuration. A parent privilege mapping should refer to a foreign key association on the current model.', null, model.name, item.property)); + } + /** + * @type {number[]} + */ + var values = accounts.map(function(x) { return x.id; }); + expr.where(entity.select(mapping.childField)).equal(perms1.select('target')) + .and(perms1.select('privilege')).equal(mapping.childModel) + .and(perms1.select('parentPrivilege')).equal(item.property) + .and(perms1.select('workspace')).equal(workspace) + .and(perms1.select('mask')).bit(requestMask,requestMask) + .and(perms1.select('account')).in(values).prepare(true); assigned=true; - cb(); + return cb(); + // #implementWhenExpression } else if (item.type==='item') { if (_.isNil(expr)) diff --git a/data-queryable.js b/data-queryable.js index 4dd7708..cb4aa34 100644 --- a/data-queryable.js +++ b/data-queryable.js @@ -38,7 +38,7 @@ DataAttributeResolver.prototype.orderByNestedAttribute = function(attr) { return DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr); }; -DataAttributeResolver.prototype.selecteNestedAttribute = function(attr, alias) { +DataAttributeResolver.prototype.selectNestedAttribute = function(attr, alias) { var expr = DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr); if (expr) { if (_.isNil(alias)) @@ -57,7 +57,7 @@ DataAttributeResolver.prototype.selecteNestedAttribute = function(attr, alias) { DataAttributeResolver.prototype.selectAggregatedAttribute = function(aggregation, attribute, alias) { var self=this, result; if (DataAttributeResolver.prototype.testNestedAttribute(attribute)) { - result = DataAttributeResolver.prototype.selecteNestedAttribute.call(self,attribute, alias); + result = DataAttributeResolver.prototype.selectNestedAttribute.call(self,attribute, alias); } else { result = self.fieldOf(attribute); @@ -489,6 +489,12 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { q =QueryUtils.query(self.viewAdapter).select(['*']); //init an entity based on association adapter (e.g. GroupMembers as members) entity = new QueryEntity(mapping.associationAdapter).as(field.name); + Object.defineProperty(entity, 'model', { + configurable: true, + enumerable: false, + writable: true, + value: mapping.associationAdapter + }); //init join expression between association adapter and current data model //e.g. Group.id = GroupMembers.parent expr = QueryUtils.query().where(QueryField.select(mapping.parentField).from(self.viewAdapter)) @@ -519,6 +525,12 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { //create new join var alias = field.name + '_' + childModel.name; entity = new QueryEntity(childModel.viewAdapter).as(alias); + Object.defineProperty(entity, 'model', { + configurable: true, + enumerable: false, + writable: true, + value: mapping.associationAdapter + }); expr = QueryUtils.query().where(QueryField.select(mapping.associationValueField).from(field.name)) .equal(QueryField.select(mapping.childField).from(alias)); //append join @@ -1228,7 +1240,7 @@ function select_(arg) { else { a = DataAttributeResolver.prototype.testNestedAttribute.call(self,arg); if (a) { - return DataAttributeResolver.prototype.selecteNestedAttribute.call(self, a.name, a.property); + return DataAttributeResolver.prototype.selectNestedAttribute.call(self, a.name, a.property); } else { a = DataAttributeResolver.prototype.testAttribute.call(self,arg); @@ -1342,7 +1354,7 @@ DataQueryable.prototype.select = function(attr) { else { b = DataAttributeResolver.prototype.testNestedAttribute.call(self,name); if (b) { - expr = DataAttributeResolver.prototype.selecteNestedAttribute.call(self, b.name, x.property); + expr = DataAttributeResolver.prototype.selectNestedAttribute.call(self, b.name, x.property); if (expr) { arr.push(expr); } } else { diff --git a/data-ref-object-listener.js b/data-ref-object-listener.js index 8e07877..3373b29 100644 --- a/data-ref-object-listener.js +++ b/data-ref-object-listener.js @@ -5,6 +5,7 @@ var {DataObjectJunction} = require('./data-object-junction'); var {DataError} = require('@themost/common'); var _ = require('lodash'); var {hasOwnProperty} = require('./has-own-property'); +var {DataObjectTag} = require('./data-object-tag'); /** * @class @@ -173,7 +174,7 @@ function beforeRemoveChildConnectedObjects(event, mapping, callback) { target = event.model.convert(event.target), parentModel = event.model, parentField = parentModel.getAttribute(mapping.parentField); - var junction = new HasParentJunction(target, mapping); + var junction= childModel == null ? new DataObjectTag(target, mapping) : new HasParentJunction(target, mapping); return parentModel.where(parentModel.primaryKey).equal(target.getId()) .select(parentField.name) .cache(false) diff --git a/has-parent-junction.d.ts b/has-parent-junction.d.ts index bddcf3a..05a14f2 100644 --- a/has-parent-junction.d.ts +++ b/has-parent-junction.d.ts @@ -5,6 +5,7 @@ import {DataAssociationMapping, DataField} from "./types"; import {DataModel} from "./data-model"; export declare class HasParentJunction extends DataQueryable { + constructor(target: any, association: DataAssociationMapping | string); parent: DataObject; mapping: DataAssociationMapping; getBaseModel(): DataModel; diff --git a/jest.setup.js b/jest.setup.js index e2c19d6..3a8e49a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -3,5 +3,7 @@ const { JsonLogger } = require('@themost/json-logger'); TraceUtils.useLogger(new JsonLogger({ format: 'raw' })); +/* env */ +process.env.NODE_ENV = 'development'; /* global jest */ jest.setTimeout(30000); diff --git a/package-lock.json b/package-lock.json index 5b6638a..0aaaffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/data", - "version": "2.6.50", + "version": "2.6.51", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.6.50", + "version": "2.6.51", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.3.0", diff --git a/package.json b/package.json index 2ba9456..1cd356f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.6.50", + "version": "2.6.51", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "types": "index.d.ts", diff --git a/spec/DataModelFilterParser.spec.ts b/spec/DataModelFilterParser.spec.ts new file mode 100644 index 0000000..0833b9e --- /dev/null +++ b/spec/DataModelFilterParser.spec.ts @@ -0,0 +1,98 @@ +import {DataModelFilterParser} from '../data-model-filter.parser'; +import {TestApplication} from './TestApplication'; +import {DataContext} from '../types'; +import {resolve} from 'path'; + +describe('DataModelFilterParser', () => { + + let app: TestApplication; + let context: DataContext; + beforeAll((done) => { + app = new TestApplication(resolve(__dirname, 'test2')); + context = app.createContext(); + return done(); + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + + it('should parse filter statement', async () => { + const Orders = context.model('Order').silent(); + const resolver = new DataModelFilterParser(Orders); + const { $where, $expand } = await resolver.parseAsync( + `orderStatus/alternateName eq 'OrderDelivered' and orderedItem/category eq 'Laptops'` + ); + const q = Orders.asQueryable(); + Object.assign(q.query, { + $where, + $expand + }); + const items: { orderStatus: any, orderedItem: { category: string } }[] = await q.take(25).getItems(); + expect(items).toBeTruthy(); + for (const item of items) { + const { orderStatus, orderedItem } = item; + expect(orderStatus.alternateName).toEqual('OrderDelivered'); + expect(orderedItem.category).toEqual('Laptops'); + } + }); + + it('should parse filter statement and execute native query', async () => { + const Orders = context.model('Order').silent(); + const resolver = new DataModelFilterParser(Orders); + const { $where, $expand } = await resolver.parseAsync( + `orderStatus/alternateName eq 'OrderDelivered' and orderedItem/category eq 'Laptops'` + ); + const q = Orders.asQueryable(); + Object.assign(q.query, { + $where, + $expand + }); + const { id: orderStatus} = await context.model('OrderStatusType') + .find({ alternateName: 'OrderDelivered' }).getItem(); + q.select(); + const items: any[] = await new Promise((resolve, reject) => { + void context.db.execute(q.query, [], (err, items) => { + if (err) { + return reject(err); + } + return resolve(items); + }); + }); + expect(items).toBeTruthy(); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item.orderStatus).toEqual(orderStatus); + } + }); + + it('should parse filter statement with previous state', async () => { + const Orders = context.model('Order').silent(); + const resolver = new DataModelFilterParser(Orders); + const { $where, $expand } = await resolver.parseAsync( + `orderStatus/alternateName eq 'OrderDelivered' and orderedItem/category eq 'Laptops'` + ); + const q = Orders.asQueryable(); + Object.assign(q.query, { + $where, + $expand + }); + const { id: orderStatus} = await context.model('OrderStatusType') + .find({ alternateName: 'OrderDelivered' }).getItem(); + q.select(); + const items: any[] = await new Promise((resolve, reject) => { + void context.db.execute(q.query, [], (err, items) => { + if (err) { + return reject(err); + } + return resolve(items); + }); + }); + expect(items).toBeTruthy(); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item.orderStatus).toEqual(orderStatus); + } + }); + +}); diff --git a/spec/DataPrivileges.spec.ts b/spec/DataPrivileges.spec.ts index c82a67b..2e10438 100644 --- a/spec/DataPrivileges.spec.ts +++ b/spec/DataPrivileges.spec.ts @@ -21,6 +21,13 @@ describe('Permissions', () => { expect(items.length).toBeTruthy(); }); + it('should have no read access', async () => { + const Orders = context.model('Order'); + const items = await Orders.getItems(); + expect(Array.isArray(items)).toBeTruthy(); + expect(items.length).toBeFalsy(); + }); + it('should validate write access', async () => { const Products = context.model('Product'); const item = await Products.where('name').equal( @@ -95,12 +102,26 @@ describe('Permissions', () => { .getItem(); expect(agent).toBeTruthy(); const OrderActions = context.model('OrderAction'); - let newAction = { + let newAction: { id?: number, agent: any; orderedItem: any; customer: any } = { orderedItem, customer, agent }; await expect(OrderActions.save(newAction)).resolves.toBeTruthy(); + // try to update the action (should fail) + const { id } = newAction; + const updateAction = await OrderActions.where('id').equal(id).getItem(); + expect(updateAction).toBeTruthy(); + updateAction.actionStatus = { + alternateName: 'CompletedActionStatus' + }; + await expect(OrderActions.save(updateAction)).rejects.toThrow('Access Denied'); + + expect(updateAction).toBeTruthy(); + updateAction.actionStatus = { + alternateName: 'ActiveActionStatus' + }; + await expect(OrderActions.save(updateAction)).resolves.toBeTruthy(); }); diff --git a/spec/GlobalPrivileges.spec.ts b/spec/GlobalPrivileges.spec.ts new file mode 100644 index 0000000..87ed707 --- /dev/null +++ b/spec/GlobalPrivileges.spec.ts @@ -0,0 +1,509 @@ +import { resolve } from 'path'; +import {DataCacheFinalize, DataCacheStrategy, DataContext} from '../index'; +import { TestApplication } from './TestApplication'; +import { TestUtils } from './adapter/TestUtils'; +const executeInTransaction = TestUtils.executeInTransaction; +describe('Global permissions', () => { + let app: TestApplication; + let context: DataContext; + beforeAll(async () => { + app = new TestApplication(resolve(__dirname, 'test2')); + context = app.createContext(); + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + afterEach(async () => { + delete context.user; + const configuration = context.getConfiguration(); + // @ts-ignore + delete configuration.cache; + const service = configuration.getStrategy(DataCacheStrategy) as unknown as DataCacheFinalize; + await service.clear(); + }); + + it('should validate anonymous read access to products', async () => { + const Products = context.model('Product'); + const items = await Products.getItems(); + expect(Array.isArray(items)).toBeTruthy(); + expect(items.length).toBeTruthy(); + }); + + it('should validate anonymous read access to a model which does not have privileges', async () => { + await context.executeInTransactionAsync(async () => { + await context.model('NavigationElement').silent().save({ + name: 'Home', + url: '/' + }); + let items = await context.model('NavigationElement').silent().getItems(); + expect(items.length).toBeTruthy(); + const NavigationElements = context.model('NavigationElement'); + items = await NavigationElements.getItems(); + expect(items.length).toBeFalsy(); + }); + }); + + it('should validate anonymous read access to nested objects', async () => { + await context.executeInTransactionAsync(async () => { + let product = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .silent().getItem(); + Object.assign(product, { + productDimensions: { + height: 0.136, + width: 0.069 + } + }); + await context.model('Product').silent().save(product); + const Products = context.model('Product'); + const item: { productDimensions?: { height: number, width: number } } = + await Products.where('name').equal('Samsung Galaxy S4').expand( + 'productDimensions' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.productDimensions).toBeTruthy(); + expect(item.productDimensions.height).toEqual(0.136); + }); + }); + + it('should validate anonymous read access to values', async () => { + await context.executeInTransactionAsync(async () => { + let product = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .silent().getItem(); + Object.assign(product, { + keywords: [ + 'Samsung', 'Galaxy', 'Smartphone' + ] + }); + await context.model('Product').silent().save(product); + const Products = context.model('Product'); + const item: { name: string, keywords?: string[] } = + await Products.where('name').equal('Samsung Galaxy S4').expand( + 'keywords' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.keywords).toBeTruthy(); + expect(item.keywords.length).toBeTruthy(); + }); + }); + + it('should validate that user does not have access to select values', async () => { + await context.executeInTransactionAsync(async () => { + let product = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .silent().getItem(); + Object.assign(product, { + tags: [ + 'Obsolete', 'Discontinued' + ] + }); + await context.model('Product').silent().save(product); + const Products = context.model('Product'); + const itemWithTag: { id?: number, name: string, tag?: string } = await Products + .where('name').equal('Samsung Galaxy S4') + .select( + 'id', + 'name', + 'tags/value as tag' + ).getItem(); + expect(itemWithTag).toBeFalsy(); + }); + }); + + it('should validate that user does not have access to select values from nested objects', async () => { + await context.executeInTransactionAsync(async () => { + Object.assign(context, { + user: { + name: 'margaret.davis@example.com' + } + }); + let users = await context.model('User') + .where('groups/name').equal('Administrators').getItems(); + expect(users.length).toBeFalsy(); + users = await context.model('User') + .where('groups/name').equal('Users').getItems(); + expect(users.length).toBeTruthy(); + expect(users.length).toEqual(1); + const [user] = users; + expect(user.name).toEqual('margaret.davis@example.com'); + + }); + }); + + it('should validate that user does not have access to select an associated object', async () => { + await context.executeInTransactionAsync(async () => { + const Products = context.model('Product'); + Object.assign(context, { + user: { + name: 'margaret.davis@example.com' + } + }); + + const orderCount = await context.model('Order').where('orderedItem/name').equal('Samsung Galaxy S4').silent().count(); + expect(orderCount).toBeTruthy(); + const items: { id?: number, customer?: number, name: string, orderDate?: Date }[] = await Products + .where('name').equal('Samsung Galaxy S4') + .select( + 'id', + 'name', + 'orders/customer as customer', + 'orders/customer/user as user', + 'orders/orderDate as orderDate' + ).getItems(); + expect(items.length).toEqual(1); + const [item] = items; + expect(item.orderDate).toBeFalsy(); + expect(item.customer).toBeFalsy(); + }); + }); + + it('should validate that user does not have access to values', async () => { + await context.executeInTransactionAsync(async () => { + let product = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .silent().getItem(); + Object.assign(product, { + tags: [ + 'Obsolete', 'Discontinued' + ] + }); + await context.model('Product').silent().save(product); + const Products = context.model('Product'); + let item: { name: string, tags?: string[] } = + await Products.where('name').equal('Samsung Galaxy S4').expand( + 'tags' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.tags).toBeTruthy(); + expect(item.tags.length).toBeFalsy(); + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + item = await Products.where('name').equal('Samsung Galaxy S4').expand( + 'tags' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.tags).toBeTruthy(); + expect(item.tags.length).toBeTruthy(); + }); + }); + + it('should validate that user does not have access to many-to-many association', async () => { + await context.executeInTransactionAsync(async () => { + let itemOffered = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .silent().getItem(); + const price = itemOffered.price * 0.75 + await context.model('SpecialOffer').silent().save({ + itemOffered, + price, + validFrom: new Date(2024, 1, 1), + validThrough: new Date(2024, 1, 15), + }); + itemOffered = await context.model('Product') + .where('name').equal('Samsung Galaxy S4') + .expand('specialOffers') + .silent().getItem(); + expect(itemOffered.specialOffers).toBeTruthy(); + expect(itemOffered.specialOffers.length).toBeTruthy(); + const Products = context.model('Product'); + let item: { name: string, price: number, specialOffers?: string[] } = + await Products.where('name').equal('Samsung Galaxy S4').expand( + 'specialOffers' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.specialOffers).toBeTruthy(); + expect(item.specialOffers.length).toBeFalsy(); + + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + item = await Products.where('name').equal('Samsung Galaxy S4').expand( + 'tags' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.specialOffers).toBeTruthy(); + expect(item.specialOffers.length).toBeTruthy(); + + }); + }); + + it('should validate anonymous write access to products', async () => { + const Products = context.model('Product'); + const item = await Products.where('name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItem(); + expect(item).toBeTruthy(); + expect(item.name).toBe('Apple MacBook Air (13.3-inch, 2013 Version)'); + item.model = 'APPLE-MACBOOK-AIR-13.3-2013'; + await expect(Products.save(item)).rejects.toThrow('Access Denied'); + }); + + it('should validate admin write access to products', async () => { + await executeInTransaction(context,async () => { + const Products = context.model('Product'); + let item = await Products.where('name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItem(); + Object.assign(context, { + user: { + name: 'alexis.rees@example.com' + } + }); + expect(item.model).not.toBe('APPLE-MACBOOK-AIR-13.3-2013'); + expect(item).toBeTruthy(); + expect(item.name).toBe('Apple MacBook Air (13.3-inch, 2013 Version)'); + item.model = 'APPLE-MACBOOK-AIR-13.3-2013'; + await expect(Products.save(item)).resolves.toBeTruthy(); + item = await Products.where('model').equal( + 'APPLE-MACBOOK-AIR-13.3-2013' + ).getItem(); + expect(item).toBeTruthy(); + }); + }); + + it('should validate admin delete access to products', async () => { + await executeInTransaction(context,async () => { + Object.assign(context, { + user: { + name: 'alexis.rees@example.com' + } + }); + const Products = context.model('Product'); + await Products.insert({ + name: 'Apple MacBook Air (13.3-inch, 2022 Version)', + model: 'APPLE-MACBOOK-AIR-13.3-2022', + description: 'The MacBook Air is a line of laptop computers developed and manufactured by Apple Inc.', + keywords: [ + 'Apple', 'MacBook', 'Air', '13.3-inch', '2022' + ] + }); + let item = await Products.where('name').equal( + 'Apple MacBook Air (13.3-inch, 2022 Version)' + ).getItem(); + await expect(Products.remove(item)).resolves.toBeUndefined(); + item = await Products.where('name').equal( + 'Apple MacBook Air (13.3-inch, 2022 Version)' + ).getItem(); + expect(item).toBeFalsy(); + }); + }); + + it('should validate read access to orders', async () => { + await executeInTransaction(context,async () => { + const Orders = context.model('Order'); + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + let items = await Orders.where('orderedItem/name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItems(); + expect(Array.isArray(items)).toBeTruthy(); + expect(items.length).toBeFalsy(); + // add read access to contributors + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 1, // read + "workspace": 1 + } + ]); + items = await Orders.where('orderedItem/name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItems(); + expect(Array.isArray(items)).toBeTruthy(); + expect(items.length).toBeTruthy(); + + }); + }); + + it('should validate write access to orders', async () => { + await executeInTransaction(context,async () => { + const Orders = context.model('Order'); + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + // add read access to contributors + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 1, // read + "workspace": 1 + } + ]); + const items = await Orders.where('orderedItem/name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItems(); + const [order] = items; + order.orderStatus = { + name: 'Pickup' + }; + await expect(Orders.save(order)).rejects.toThrow('Access Denied'); + + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 4, // update + "workspace": 1 + } + ]); + await expect(Orders.save(order)).resolves.toBeTruthy(); + }); + }); + + it('should validate create access to orders', async () => { + await executeInTransaction(context,async () => { + const Orders = context.model('Order'); + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + // add read access to contributors + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 1, // read + "workspace": 1 + } + ]); + const items = await Orders.where('orderedItem/name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItems(); + const [order] = items; + order.orderStatus = { + name: 'Pickup' + }; + delete order.id; + order.orderNumber = '123456'; + // and try to create a copy + await expect(Orders.insert(order)).rejects.toThrow('Access Denied'); + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 2, // create + "workspace": 1 + } + ]); + await expect(Orders.insert(order)).resolves.toBeTruthy(); + await expect(Orders.where('orderNumber').equal('123456').getItem()).resolves.toBeTruthy(); + }); + }); + + it('should validate delete access to orders', async () => { + await executeInTransaction(context,async () => { + const Orders = context.model('Order'); + const user = { + name: 'margaret.davis@example.com' + } + // add user to contributors + const group = await context.model('Group').where('name').equal('Contributors').getTypedItem(); + expect(group).toBeTruthy(); + const members = group.property('members').silent(); + await members.insert(user); + Object.assign(context, { + user + }); + // add read access to contributors + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 1, // read + "workspace": 1 + } + ]); + const items = await Orders.where('orderedItem/name').equal( + 'Apple MacBook Air (13.3-inch, 2013 Version)' + ).getItems(); + const [order] = items; + order.orderStatus = { + name: 'Pickup' + }; + await expect(Orders.remove(order)).rejects.toThrow('Access Denied'); + await context.model('Permission').silent().save([ + { + "privilege": "Order", // model + "parentPrivilege": null, + "account": { + "name": "Contributors" // group + }, + "target": 0, // all + "mask": 8, // delete + "workspace": 1 + } + ]); + await expect(Orders.remove(order)).resolves.toBeUndefined(); + await expect(Orders.where('id').equal(order.id).getItem()).resolves.toBeFalsy(); + }); + }); +}); diff --git a/spec/ParentPrivileges.spec.ts b/spec/ParentPrivileges.spec.ts new file mode 100644 index 0000000..01571ce --- /dev/null +++ b/spec/ParentPrivileges.spec.ts @@ -0,0 +1,90 @@ +import { resolve } from 'path'; +import { DataContext } from '../index'; +import { TestApplication } from './TestApplication'; +import { TestUtils } from './adapter/TestUtils'; +const executeInTransaction = TestUtils.executeInTransaction; + +interface DataContextWithUser extends DataContext { + user: any +} + +describe('Parent permissions', () => { + let app: TestApplication; + let context: DataContextWithUser; + beforeAll(async () => { + app = new TestApplication(resolve(__dirname, 'test2')); + context = app.createContext() as DataContextWithUser; + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + afterEach(() => { + delete context.user; + }); + + it('should allow to read objects based on parent', async () => { + await executeInTransaction(context, async () => { + // create merchant + const merchant : { id?: number, name: string, email: string, telephone: string} = { + name: 'Weber and Sons', + email: 'weber-and-sons@example.com', + telephone: '+441234567890' + }; + await context.model('Party').silent().save(merchant); + // create order + const order = { + merchant, + orderedItem: { + name: 'Samsung Galaxy S4' + }, + orderStatus: { + name: 'Processing' + }, + customer: { + email: 'collin.jenkins@example.com' + } + }; + await context.model('Order').silent().save(order); + // create user + const user = { + name: 'tommy.jenkins@example.com', + } + await context.model('User').silent().save(user); + // create Merchants + await context.model('Group').silent().save({ + name: 'Merchants', + members: [{ + name: 'tommy.jenkins@example.com' + }] + }); + // create group for the merchant + const group = { + name: 'Weber and Sons Merchants', + alternateName: 'WeberAndSonsMerchants', + members: [{ + name: 'tommy.jenkins@example.com' + }] + }; + + await context.model('Group').silent().save(group); + // set permission based on parent + // which is a merchant + // the users who are in the group 'Weber and Sons Merchants' can read the orders + // made by the merchant 'Weber and Sons' + await context.model('Permission').silent().save({ + privilege: 'Order', + parentPrivilege: 'merchant', + account: { + name: 'Weber and Sons Merchants' + }, + mask: 1, + target: merchant.id // set target id which the identifier of the merchant + }); + context.user = user; + const items = await context.model('Order').getItems(); + expect(items.length).toBe(1); + }); + }); + +}); diff --git a/spec/test2/config/models/Order.json b/spec/test2/config/models/Order.json index 723e624..5c2bfa7 100644 --- a/spec/test2/config/models/Order.json +++ b/spec/test2/config/models/Order.json @@ -215,6 +215,13 @@ "type": "global", "account": "Administrators" }, + { + "mask": 15, + "description": "Read, create, update or delete orders based on privileges given for a specific merchant.", + "type": "parent", + "account": "Merchants", + "property": "merchant" + }, { "mask": 1, "type": "self", diff --git a/spec/test2/config/models/OrderAction.json b/spec/test2/config/models/OrderAction.json index 14a87d6..c760d7d 100644 --- a/spec/test2/config/models/OrderAction.json +++ b/spec/test2/config/models/OrderAction.json @@ -52,8 +52,30 @@ "account": "Administrators" }, { - "mask": 15, + "mask": 1, + "description": "An agent can read order actions", + "type": "global", + "account": "Contributors" + }, + { + "mask": 2, + "type": "self", + "description": "Allow create order actions with potential status", + "account": "Contributors", + "filter": "agent/user eq me() and actionStatus/alternateName eq 'PotentialActionStatus'" + }, + { + "mask": 4, + "type": "self", + "description": "Allow update order actions when action status is potential", + "account": "Contributors", + "when": "agent/user eq me() and actionStatus/alternateName eq 'PotentialActionStatus'", + "filter": "agent/user eq me() and (actionStatus/alternateName eq 'PotentialActionStatus' or actionStatus/alternateName eq 'ActiveActionStatus')" + }, + { + "mask": 8, "type": "self", + "description": "Allow delete order actions when action status is potential", "account": "Contributors", "filter": "agent/user eq me() and actionStatus/alternateName eq 'PotentialActionStatus'" } diff --git a/spec/test2/config/models/Product.json b/spec/test2/config/models/Product.json index f120884..f98878b 100644 --- a/spec/test2/config/models/Product.json +++ b/spec/test2/config/models/Product.json @@ -57,6 +57,35 @@ "description": "The release date of a product or product model. This can be used to distinguish the exact variant of a product.", "type": "Date" }, + { + "name": "tags", + "title": "Tag", + "many": true, + "type": "Text", + "mapping": { + "associationAdapter": "ProductTags", + "associationType": "junction", + "cascade": "delete", + "associationObjectField": "product", + "associationValueField": "value", + "privileges": [ + { + "mask": 15, + "type": "global" + }, + { + "mask": 15, + "type": "global", + "account": "Administrators" + }, + { + "mask": 15, + "type": "global", + "account": "Contributors" + } + ] + } + }, { "name": "keywords", "title": "Keyword", diff --git a/spec/test2/config/models/ProductDimension.json b/spec/test2/config/models/ProductDimension.json index a3cc3aa..851256e 100644 --- a/spec/test2/config/models/ProductDimension.json +++ b/spec/test2/config/models/ProductDimension.json @@ -26,5 +26,21 @@ "product" ] } + ], + "privileges": [ + { + "mask": 1, + "type": "global", + "account": "*" + }, + { + "mask": 15, + "type": "global" + }, + { + "mask": 15, + "type": "global", + "account": "Administrators" + } ] } \ No newline at end of file diff --git a/spec/test2/config/models/SpecialOffer.json b/spec/test2/config/models/SpecialOffer.json index 7348946..7f06f8c 100644 --- a/spec/test2/config/models/SpecialOffer.json +++ b/spec/test2/config/models/SpecialOffer.json @@ -9,9 +9,26 @@ "title": "itemOffered", "description": "The item being offered.", "type": "Product", + "editable": false, "nullable": false } ], "constraints": [ + ], + "privileges": [ + { + "mask": 15, + "type": "global" + }, + { + "mask": 15, + "type": "global", + "account": "Administrators" + }, + { + "mask": 15, + "type": "global", + "account": "Contributors" + } ] } \ No newline at end of file diff --git a/types.d.ts b/types.d.ts index cea9202..09eff4a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -63,6 +63,39 @@ export declare class DataAdapter { createView(name:string, query:any, callback:(err?:Error) => void): void; } + +/** + * Holds user information of a data context + */ +export interface AuthenticatedUser { + /** + * Gets or sets a string which represents the name of the user + */ + name: string; + /** + * Gets or sets a string which represents the current authentication type e.g. Basic, Bearer, None etc + */ + authenticationType?: string; + /** + * Gets or sets a string which represents a token associated with this user + */ + authenticationToken?: string; + /** + * Gets or sets a scope if current authentication type is associated with scopes like OAuth2 authentication + */ + authenticationScope?: string; + /** + * Gets or sets a key returned by authentication provider and identifies this user e.g. The id of the user + */ + authenticationProviderKey?: any; +} + +/** + * Holds user information when a data context is in unattended mode + */ +export interface InteractiveUser extends AuthenticatedUser{ +} + export declare class DataContext extends SequentialEventEmitter { model(name:any): DataModel @@ -76,6 +109,10 @@ export declare class DataContext extends SequentialEventEmitter { finalizeAsync(): Promise; executeInTransactionAsync(func: () => Promise): Promise; + + user?: AuthenticatedUser; + + interactiveUser?: InteractiveUser; } export declare class DataContextEmitter { @@ -86,6 +123,7 @@ export declare interface DataModelPrivilege { type: string; mask: number; account?: string; + when?: string; filter?: string; scope?: string[]; exclude?: string; diff --git a/types.js b/types.js index f12c491..8fb4a8b 100644 --- a/types.js +++ b/types.js @@ -758,6 +758,9 @@ var PrivilegeType = { * The defined set of permissions are automatically assigned if the requested objects fulfill filter criteria. * (e.g. read-write permissions for a user's associated person through the following expression:"user eq me()") * @property {string} account - Gets or sets a wildcard (*) expression for global privileges only. + * @property {string} when - Gets or sets a filter expression which is going to be used for self privileges. + * @property {string} exclude - Gets or sets a condition for excluding the given privilege based on that condition. + * @property {Array} scope - Gets or sets a collection of client scopes which are required for validating this privilege. * The defined set of permissions are automatically assigned to all users (e.g. read permissions for all users) */ function DataModelPrivilege() {