diff --git a/app/activity/handler.ts b/app/activity/handler.ts new file mode 100644 index 000000000..24e5f4306 --- /dev/null +++ b/app/activity/handler.ts @@ -0,0 +1,9 @@ +import * as Resource from './resource'; + +export const get = { + description: 'get all activities', + tags: ['api'], + handler: async () => { + return Resource.get(); + } +}; diff --git a/app/activity/index.ts b/app/activity/index.ts new file mode 100644 index 000000000..1f1fd20c5 --- /dev/null +++ b/app/activity/index.ts @@ -0,0 +1,16 @@ +import Hapi from '@hapi/hapi'; +import * as Handler from './handler'; + +export const plugin = { + name: 'activity', + dependencies: 'postgres', + register(server: Hapi.Server) { + server.route([ + { + method: 'GET', + path: '/api/activities', + options: Handler.get + } + ]); + } +}; diff --git a/app/activity/resource.ts b/app/activity/resource.ts new file mode 100644 index 000000000..e44a65124 --- /dev/null +++ b/app/activity/resource.ts @@ -0,0 +1,339 @@ +import { getManager } from 'typeorm'; +import { Activity } from '../../model/activity'; +import Constants from '../../utils/constant'; +import { delta } from '../../utils/deep-diff'; +import { User } from '../../model/user'; +import { Role } from '../../model/role'; +import { Group } from '../../model/group'; + +type ActivityType = { + id: string; + reason: string; + createdAt: string; + diff: { + [key: string]: string[] | undefined; + }; + user: string; +}; + +const excludeFields = ['createdAt', 'updatedAt']; + +export const actions = { + CREATE: 'create', + EDIT: 'edit', + DELETE: 'delete' +}; + +const titleMap = { + ASSIGNED_ROLE: 'Assigned a role', + ASSIGNED_USER: 'Assigned a user', + ADD_ATTRIBUTE_TO_GROUP: 'Added attribute to a team', + REMOVED_ROLE: 'Removed a role', + REMOVED_USER: 'Removed a user', + REMOVED_ATTRIBUTE_FROM_GROUP: 'Removed attribute from a team' +}; + +const mapData = (input: any[] = [], key: string) => { + return input.reduce((output, row) => { + if (!Object.prototype.hasOwnProperty.call(output, row[key])) { + // eslint-disable-next-line no-param-reassign + output[row[key]] = row; + } + return output; + }, {}); +}; + +const activityResponsePayload = (activity: Activity) => { + const activityResponse: ActivityType = { + createdAt: activity.createdAt, + diff: { created: undefined, edited: undefined, removed: undefined }, + id: activity.id, + reason: activity.title, + user: activity.createdBy.username + }; + return activityResponse; +}; + +const calcDiff = (input: Record[], key: string) => { + return input.filter((diff) => { + return diff.path[0] === key; + }); +}; + +const relationType = (diffs: Record[]) => { + const relation = { + isRole: false, + isUser: false + }; + const pType = calcDiff(diffs, 'ptype'); + let value = ''; + if (pType.length > 0) { + if (Object.prototype.hasOwnProperty.call(pType[0], 'rhs')) { + value = pType[0].rhs; + } else { + value = pType[0].lhs; + } + + if (value === 'p') { + relation.isRole = true; + } + + if (value === 'g') { + relation.isUser = true; + } + } + return relation; +}; + +const parseGroupActivity = async (activity: Activity) => { + const output = activityResponsePayload(activity); + const displayName = calcDiff(activity.diffs, 'displayname'); + const metadata = calcDiff(activity.diffs, 'metadata'); + + if (activity.documentId === '0') { + // created + output.diff.created = [displayName[0].rhs]; + if (metadata.length > 0) { + Object.keys(metadata[0].rhs).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + output.diff[key] = [metadata[0].rhs[key]]; + }); + } + } else { + if (metadata.length > 0) { + metadata.forEach((meta) => { + output.diff[meta.path[1] || ''] = [meta.lhs, meta.rhs]; + }); + } + + if ( + displayName.length > 0 && + Object.prototype.hasOwnProperty.call(displayName[0], 'lhs') + ) { + if (Object.prototype.hasOwnProperty.call(displayName[0], 'rhs')) { + output.diff.edited = [displayName[0].lhs, displayName[0].rhs]; + } else { + output.diff.removed = [displayName[0].lhs]; + } + } + } + return output; +}; + +const parseCasbinActivity = async (activity: Activity) => { + const [groups, roles] = await Promise.all([ + await Group.find(), + await Role.find() + ]); + const groupMap = mapData(groups, 'id'); + const roleMap = mapData(roles, 'id'); + const output = activityResponsePayload(activity); + const relation = relationType(activity.diffs); + const isDocumentEmpty = Object.keys(activity.document).length === 0; + if (isDocumentEmpty) { + if (relation.isRole) { + const userDiff: any = calcDiff(activity.diffs, 'subject'); + const groupDiff: any = calcDiff(activity.diffs, 'resource'); + const roleDiff: any = calcDiff(activity.diffs, 'action'); + const role = roleMap[roleDiff[0]?.rhs?.role || '']; + const group = groupMap[groupDiff[0]?.rhs?.group || '']; + const user = await User.findOne({ + select: ['displayname'], + where: { + id: userDiff[0]?.rhs?.user + } + }); + output.diff.created = [ + `Assigned a role ${role?.displayname || ''} ${ + user?.displayname ? `to user ${user?.displayname}` : '' + } for team ${group?.displayname || ''}` + ]; + } else if (relation.isUser) { + const userDiff: any = calcDiff(activity.diffs, 'subject'); + const groupDiff: any = calcDiff(activity.diffs, 'resource'); + const group = groupMap[groupDiff[0]?.rhs?.group || '']; + const user = await User.findOne({ + select: ['displayname'], + where: { + id: userDiff[0]?.rhs?.user + } + }); + output.diff.created = [ + `Assigned a user ${user?.displayname || ''} to team ${ + group?.displayname || '' + }` + ]; + } + } else if (relation.isRole) { + const { role } = activity.document.action; + const { group } = activity.document.subject; + output.diff.removed = [ + `Removed a role ${roleMap[role]?.displayname || ''} from team ${ + groupMap[group]?.displayname || '' + }`, + '' + ]; + } else if (relation.isUser) { + const user = await User.findOne({ + select: ['displayname'], + where: { + id: activity.document?.subject?.user + } + }); + const { group } = activity.document?.resource; + output.diff.removed = [ + `Remove a user ${user?.displayname || ''} from team ${ + groupMap[group]?.displayname || '' + }`, + '' + ]; + } + return output; +}; + +export const get = async (groupId = '') => { + let whereClause = + '( activity.title != :addAttributeToGroup AND activity.title != :removeAttributeFromGroup )'; + const whereParameter = { + addAttributeToGroup: titleMap.ADD_ATTRIBUTE_TO_GROUP, + removeAttributeFromGroup: titleMap.REMOVED_ATTRIBUTE_FROM_GROUP, + createGroup: '', + userRoleGroupMap: '', + groupId: '' + }; + + const ActivityRepository = getManager().getRepository(Activity); + if (groupId) { + whereClause += + ' AND ( activity.diffs @> :createGroup OR activity.diffs @> :userRoleGroupMap ) '; + whereParameter.createGroup = JSON.stringify([{ rhs: groupId }]); + whereParameter.userRoleGroupMap = JSON.stringify([ + { + rhs: { group: groupId } + } + ]); + } + + const activities = await ActivityRepository.createQueryBuilder('activity') + .where(whereClause, whereParameter) + .orderBy('activity.created_at', 'DESC') + .skip(0) + .take(50) + .getMany(); + + return await Promise.all( + activities.map(async (activity) => { + let output: ActivityType = { + createdAt: '', + diff: { created: undefined, edited: undefined, removed: undefined }, + id: '', + reason: '', + user: '' + }; + + if (activity.model === Constants.MODEL.Group) { + output = await parseGroupActivity(activity); + } else if (activity.model === Constants.MODEL.CasbinRule) { + output = await parseCasbinActivity(activity); + } + return output; + }) + ); +}; + +export const create = async (payload: any) => { + if (payload?.diffs && payload.diffs.length > 0) { + return await Activity.save({ ...payload }); + } + return null; +}; + +const getTitle = (event: any, type: string) => { + let title = ''; + switch (type) { + case actions.CREATE: + if (event.metadata.tableName === Constants.MODEL.Group) { + title = `Created ${event.entity?.displayname} team `; + } else if (event.metadata.tableName === Constants.MODEL.CasbinRule) { + if (event.entity?.ptype === 'p') { + title = titleMap.ASSIGNED_ROLE; + } else if (event.entity?.ptype === 'g') { + title = titleMap.ASSIGNED_USER; + } else if (event.entity?.ptype === 'g2') { + title = titleMap.ADD_ATTRIBUTE_TO_GROUP; + } + } + break; + case actions.EDIT: + if (event.metadata.tableName === Constants.MODEL.Group) { + title = `Edited ${event.entity?.displayname}`; + } else if (event.metadata.tableName === Constants.MODEL.CasbinRule) { + title = `Edited ${event.entity?.ptype} Casbin Rule `; + } + break; + case actions.DELETE: + if (event.metadata.tableName === Constants.MODEL.Group) { + title = `Deleted ${event.entity?.displayname} team`; + } else if (event.metadata.tableName === Constants.MODEL.CasbinRule) { + if (event.databaseEntity?.ptype === 'p') { + title = titleMap.REMOVED_ROLE; + } else if (event.databaseEntity?.ptype === 'g') { + title = titleMap.REMOVED_USER; + } else if (event.databaseEntity?.ptype === 'g2') { + title = titleMap.REMOVED_ATTRIBUTE_FROM_GROUP; + } + } + break; + default: + title = ''; + } + + return title; +}; + +export const log = async (event: any, type: string) => { + let promise = null; + const title = getTitle(event, type); + switch (type) { + case actions.CREATE: + promise = create({ + document: {}, + title, + documentId: '0', + model: event.metadata.tableName, + diffs: delta({}, event.entity || {}, { + exclude: excludeFields + }), + createdBy: event.queryRunner.data.user + }); + break; + case actions.EDIT: + promise = create({ + document: event.databaseEntity, + title, + documentId: event.databaseEntity?.id || '0', + model: event.metadata.tableName, + diffs: delta(event.databaseEntity || {}, event.entity || {}, { + exclude: excludeFields + }), + createdBy: event.queryRunner.data.user + }); + break; + case actions.DELETE: + promise = create({ + document: event.databaseEntity, + title, + documentId: event.databaseEntity?.id || '0', + model: event.metadata.tableName, + diffs: delta(event.databaseEntity || {}, event.entity || {}, { + exclude: excludeFields + }), + createdBy: event.queryRunner.data.user + }); + break; + default: + promise = Promise.resolve(); + } + await promise; +}; diff --git a/app/activity/schema.ts b/app/activity/schema.ts new file mode 100644 index 000000000..97383a8e8 --- /dev/null +++ b/app/activity/schema.ts @@ -0,0 +1,18 @@ +import Joi from 'joi'; +import Config from '../../config/config'; + +const validationOptions = Config.get('/validationOptions'); + +export const ActivityPayload = Joi.object() + .label('ActivityPayload') + .keys({ + id: Joi.string().required(), + title: Joi.string().required(), + model: Joi.string().required(), + documentId: Joi.string().required(), + document: Joi.object().required(), + diffs: Joi.array().items(Joi.object().optional()), + createdBy: Joi.string().required(), + createdAt: Joi.date().iso().required() + }) + .options(validationOptions); diff --git a/app/group/handler.ts b/app/group/handler.ts index 057e3394a..27724db89 100644 --- a/app/group/handler.ts +++ b/app/group/handler.ts @@ -1,6 +1,7 @@ import Hapi from '@hapi/hapi'; import * as Schema from './schema'; import * as Resource from './resource'; +import { get as ActivitiesByGroup } from '../activity/resource'; export const list = { description: 'get list of groups', @@ -51,3 +52,12 @@ export const update = { ); } }; + +export const getActivitiesByGroup = { + description: 'get activities by group', + tags: ['api'], + handler: async (request: Hapi.Request) => { + const { id: groupId } = request.params; + return ActivitiesByGroup(groupId); + } +}; diff --git a/app/group/index.ts b/app/group/index.ts index b03895430..b57288f68 100644 --- a/app/group/index.ts +++ b/app/group/index.ts @@ -27,6 +27,11 @@ export const plugin = { method: 'PUT', path: '/api/groups/{id}', options: Handler.update + }, + { + method: 'GET', + path: '/api/groups/{id}/activities', + options: Handler.getActivitiesByGroup } ]; diff --git a/app/group/resource.ts b/app/group/resource.ts index 7e6e6ee84..df6216554 100644 --- a/app/group/resource.ts +++ b/app/group/resource.ts @@ -8,6 +8,7 @@ import { toLikeQuery } from '../policy/util'; import CasbinSingleton from '../../lib/casbin'; import { parseGroupListResult } from './util'; import getUniqName, { validateUniqName } from '../../lib/getUniqName'; +import { User } from '../../model/user'; type JSObj = Record; @@ -55,20 +56,29 @@ export const checkSubjectHasAccessToCreateAttributesMapping = async ( export const upsertGroupAndAttributesMapping = async ( groupId: string, - attributes: JSObj[] + attributes: JSObj[], + loggedInUser: User | undefined ) => { if (R.isEmpty(attributes)) { return; } - - await CasbinSingleton?.enforcer?.removeAllResourceGroupingJsonPolicy({ - group: groupId - }); + const options = { created_by: loggedInUser }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await CasbinSingleton?.enforcer?.removeAllResourceGroupingJsonPolicy( + { + group: groupId + }, + options + ); await Promise.all( attributes.map(async (attribute: JSObj) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await CasbinSingleton?.enforcer?.addResourceGroupingJsonPolicy( { group: groupId }, - attribute + attribute, + options ); }) ); @@ -92,7 +102,11 @@ export const checkSubjectHasAccessToEditGroup = async ( ) => { const groupId = group.id; const prevAttributes = group.attributes; - + const user = await User.findOne({ + where: { + id: loggedInUserId + } + }); // ? We need to check this only if the attributes change if (!R.equals(attributes, prevAttributes)) { await checkSubjectHasAccessToCreateAttributesMapping( @@ -101,7 +115,7 @@ export const checkSubjectHasAccessToEditGroup = async ( }, [...attributes, ...prevAttributes] ); - await upsertGroupAndAttributesMapping(groupId, attributes); + await upsertGroupAndAttributesMapping(groupId, attributes, user); } // ? the user needs access to the group if they need to edit it @@ -198,11 +212,19 @@ export const create = async (payload: any, loggedInUserId: string) => { attributes ); + const user = await User.findOne({ + where: { + id: loggedInUserId + } + }); const groupname = await getValidGroupname(groupPayload); - const groupResult = await Group.save({ ...groupPayload, groupname }); + const groupResult = await Group.save( + { ...groupPayload, groupname }, + { data: { user } } + ); const groupId = groupResult.id; - await upsertGroupAndAttributesMapping(groupId, attributes); + await upsertGroupAndAttributesMapping(groupId, attributes, user); const policyOperationResult = await bulkUpsertPoliciesForGroup( groupId, @@ -221,6 +243,11 @@ export const update = async ( const { policies = [], attributes = [], ...groupPayload } = payload; const groupWithExtraKeys = await get(groupId, loggedInUserId); const group = R.omit(['policies', 'attributes'], groupWithExtraKeys); + const user = await User.findOne({ + where: { + id: loggedInUserId + } + }); // ? We need to check this only if the attributes change await checkSubjectHasAccessToEditGroup( @@ -229,7 +256,7 @@ export const update = async ( loggedInUserId ); - await Group.save({ ...group, ...groupPayload }); + await Group.save({ ...group, ...groupPayload }, { data: { user } }); const policyOperationResult = await bulkUpsertPoliciesForGroup( groupId, diff --git a/app/group/user/handler.ts b/app/group/user/handler.ts index ddc0c1087..865e5ac3a 100644 --- a/app/group/user/handler.ts +++ b/app/group/user/handler.ts @@ -67,7 +67,8 @@ export const remove = { app: iamConfig('iam.delete'), handler: async (request: Hapi.Request) => { const { groupId, userId } = request.params; - return Resource.remove(groupId, userId); + const { id: loggedInUserId } = request.auth.credentials; + return Resource.remove(groupId, userId, loggedInUserId); } }; @@ -77,6 +78,10 @@ export const removeSelf = { handler: async (request: Hapi.Request) => { const { groupId } = request.params; const { id: loggedInUserId } = request.auth.credentials; - return Resource.remove(groupId, loggedInUserId); + return Resource.remove( + groupId, + loggedInUserId, + loggedInUserId + ); } }; diff --git a/app/group/user/resource.ts b/app/group/user/resource.ts index 2d5fbeb07..46ddb1432 100644 --- a/app/group/user/resource.ts +++ b/app/group/user/resource.ts @@ -22,8 +22,19 @@ export const create = async ( const group = { group: groupId }; - - await CasbinSingleton.enforcer?.addSubjectGroupingJsonPolicy(subject, group); + const user = await User.findOne({ + where: { + id: loggedInUserId + } + }); + const options = { created_by: user }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await CasbinSingleton.enforcer?.addSubjectGroupingJsonPolicy( + subject, + group, + options + ); return await bulkOperation(policies, { user: loggedInUserId }); }; @@ -41,13 +52,25 @@ export const update = async ( return await bulkOperation(policies, subject); }; -export const remove = async (groupId: string, userId: string) => { +export const remove = async ( + groupId: string, + userId: string, + loggedInUserId: string +) => { const userObj = { user: userId }; const groupObj = { group: groupId }; - + const user = await User.findOne({ + where: { + id: loggedInUserId + } + }); + const options = { created_by: user }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await CasbinSingleton.enforcer?.removeSubjectGroupingJsonPolicy( userObj, - groupObj + groupObj, + options ); const policies = await getPoliciesBySubject(userObj, groupObj); @@ -57,7 +80,8 @@ export const remove = async (groupId: string, userId: string) => { await CasbinSingleton.enforcer?.removeJsonPolicy( policy.subject, policy.resource, - policy.action + policy.action, + options ); }); await Promise.all(promiseList); diff --git a/app/policy/resource.ts b/app/policy/resource.ts index b33ea06c3..5ffecafda 100644 --- a/app/policy/resource.ts +++ b/app/policy/resource.ts @@ -22,6 +22,11 @@ export const bulkOperation = async ( policyOperations: PolicyOperation[] = [], subject: JSObj ) => { + const user = await User.findOne({ + where: { + id: subject.user + } + }); const promiseList = policyOperations.map(async ({ operation, ...policy }) => { // ? the subject who is performing the action should have iam.manage permission const hasAccess = await CasbinSingleton.enforcer?.enforceJson( @@ -31,12 +36,14 @@ export const bulkOperation = async ( ); if (!hasAccess) return false; + const options: JSObj = { created_by: user }; switch (operation) { case 'create': { await CasbinSingleton.enforcer?.addJsonPolicy( policy.subject, policy.resource, - policy.action + policy.action, + options ); break; } @@ -44,7 +51,8 @@ export const bulkOperation = async ( await CasbinSingleton.enforcer?.removeJsonPolicy( policy.subject, policy.resource, - policy.action + policy.action, + { created_by: user } ); break; } diff --git a/config/composer.ts b/config/composer.ts index f266232ce..8d736255a 100644 --- a/config/composer.ts +++ b/config/composer.ts @@ -61,6 +61,9 @@ internals.manifest = { { plugin: '../app/role/index' }, + { + plugin: '../app/activity/index' + }, { plugin: '../app/access/index' }, diff --git a/factory/activity.ts b/factory/activity.ts new file mode 100644 index 000000000..3a5827835 --- /dev/null +++ b/factory/activity.ts @@ -0,0 +1,14 @@ +import Faker from 'faker'; +import { define } from 'typeorm-seeding'; +import { Activity } from '../model/activity'; + +define(Activity, (faker: typeof Faker) => { + const activity = new Activity(); + activity.id = faker.random.uuid(); + activity.title = faker.random.words(3).toString(); + activity.model = 'User'; + activity.document = {}; + activity.documentId = faker.random.uuid(); + activity.diffs = []; + return activity; +}); diff --git a/lib/casbin/IEnforcer.ts b/lib/casbin/IEnforcer.ts index b416f867b..541d23894 100644 --- a/lib/casbin/IEnforcer.ts +++ b/lib/casbin/IEnforcer.ts @@ -19,45 +19,58 @@ interface IEnforcer { addJsonPolicy( subject: JsonAttributes, resource: JsonAttributes, - action: JsonAttributes + action: JsonAttributes, + options: JsonAttributes ): void; - addStrPolicy(subject: string, resource: string, action: string): void; + addStrPolicy( + subject: string, + resource: string, + action: string, + options: JsonAttributes + ): void; removeJsonPolicy( subject: JsonAttributes, resource: JsonAttributes, - action: JsonAttributes + action: JsonAttributes, + options: JsonAttributes ): void; addSubjectGroupingJsonPolicy( subject: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ): void; removeSubjectGroupingJsonPolicy( subject: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ): void; addResourceGroupingJsonPolicy( resource: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ): void; // ? Note: this will remove all policies by resource keys and then insert the new one upsertResourceGroupingJsonPolicy( resource: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ): void; removeAllResourceGroupingJsonPolicy( - resource: OneKey + resource: OneKey, + options: JsonAttributes ): void; addActionGroupingJsonPolicy( action: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ): void; } diff --git a/lib/casbin/JsonFilteredEnforcer.ts b/lib/casbin/JsonFilteredEnforcer.ts index e1569f649..c9ec5f4dd 100644 --- a/lib/casbin/JsonFilteredEnforcer.ts +++ b/lib/casbin/JsonFilteredEnforcer.ts @@ -5,6 +5,10 @@ import { newEnforcerWithClass } from 'casbin'; import { convertJSONToStringInOrder, JsonEnforcer } from './JsonEnforcer'; import { toLikeQuery } from '../../app/policy/util'; import IEnforcer, { JsonAttributes, OneKey, PolicyObj } from './IEnforcer'; +import { + log as ActivityLog, + actions as ActivityActions +} from '../../app/activity/resource'; const groupPolicyParameters = (policies: PolicyObj[]) => { const res = policies.reduce( @@ -23,6 +27,27 @@ const groupPolicyParameters = (policies: PolicyObj[]) => { }; }; +const sendLog = async (policy: any, type: string, user: any) => { + const log = { + entity: policy || {}, + databaseEntity: {}, + metadata: { + tableName: 'casbin_rule' + }, + queryRunner: { + data: { + user + } + } + }; + + if (type === ActivityActions.DELETE) { + log.databaseEntity = log.entity; + log.entity = {}; + } + return ActivityLog(log, type); +}; + export class JsonFilteredEnforcer implements IEnforcer { public static params: any[]; @@ -106,6 +131,13 @@ export class JsonFilteredEnforcer implements IEnforcer { return R.uniq(actionMappings.map((aM) => aM.v1).concat(actions)); } + private async getAllPolicies() { + return await createQueryBuilder() + .select('*') + .from('casbin_rule', 'casbin_rule') + .getRawMany(); + } + private async getEnforcerWithPolicies(policies: PolicyObj[]) { const enforcer = await this.getEnforcer(); const { subjects, resources, actions } = groupPolicyParameters(policies); @@ -191,7 +223,8 @@ export class JsonFilteredEnforcer implements IEnforcer { public async addJsonPolicy( subject: JsonAttributes, resource: JsonAttributes, - action: JsonAttributes + action: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.addPolicy( @@ -199,17 +232,33 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(resource), convertJSONToStringInOrder(action) ); + await sendLog( + { ptype: 'p', subject, resource, action }, + ActivityActions.CREATE, + options.created_by + ); } - public async addStrPolicy(subject: string, resource: string, action: string) { + public async addStrPolicy( + subject: string, + resource: string, + action: string, + options: JsonAttributes + ) { const enforcer = await this.getEnforcer(); await enforcer.addPolicy(subject, resource, action); + await sendLog( + { subject, resource, action }, + ActivityActions.CREATE, + options.created_by + ); } public async removeJsonPolicy( subject: JsonAttributes, resource: JsonAttributes, - action: JsonAttributes + action: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.removeFilteredNamedPolicy( @@ -219,11 +268,17 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(resource), convertJSONToStringInOrder(action) ); + await sendLog( + { ptype: 'p', subject, resource, action }, + ActivityActions.DELETE, + options.created_by + ); } public async addSubjectGroupingJsonPolicy( subject: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.addNamedGroupingPolicy( @@ -232,11 +287,17 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(jsonAttributes), 'subject' ); + await sendLog( + { ptype: 'g', subject, resource: jsonAttributes, action: 'subject' }, + ActivityActions.CREATE, + options.created_by + ); } public async removeSubjectGroupingJsonPolicy( subject: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.removeFilteredNamedGroupingPolicy( @@ -246,11 +307,17 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(jsonAttributes), 'subject' ); + await sendLog( + { ptype: 'g', subject, resource: jsonAttributes, action: 'subject' }, + ActivityActions.DELETE, + options.created_by + ); } public async addResourceGroupingJsonPolicy( resource: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.addNamedGroupingPolicy( @@ -259,19 +326,31 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(jsonAttributes), 'resource' ); + await sendLog( + { + ptype: 'g2', + subject: resource, + resource: jsonAttributes, + action: 'resource' + }, + ActivityActions.CREATE, + options.created_by + ); } // ? Note: this will remove all policies by resource keys and then insert the new one public async upsertResourceGroupingJsonPolicy( resource: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ) { - await this.removeAllResourceGroupingJsonPolicy(resource); - await this.addResourceGroupingJsonPolicy(resource, jsonAttributes); + await this.removeAllResourceGroupingJsonPolicy(resource, options); + await this.addResourceGroupingJsonPolicy(resource, jsonAttributes, options); } public async removeAllResourceGroupingJsonPolicy( - resource: OneKey + resource: OneKey, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.removeFilteredNamedGroupingPolicy( @@ -279,11 +358,22 @@ export class JsonFilteredEnforcer implements IEnforcer { 0, convertJSONToStringInOrder(resource) ); + await sendLog( + { + ptype: 'g2', + subject: resource, + resource: {}, + action: 'resource' + }, + ActivityActions.DELETE, + options.created_by + ); } public async addActionGroupingJsonPolicy( action: OneKey, - jsonAttributes: JsonAttributes + jsonAttributes: JsonAttributes, + options: JsonAttributes ) { const enforcer = await this.getEnforcer(); await enforcer.addNamedGroupingPolicy( @@ -292,6 +382,16 @@ export class JsonFilteredEnforcer implements IEnforcer { convertJSONToStringInOrder(jsonAttributes), 'action' ); + await sendLog( + { + ptype: 'g3', + subject: action, + resource: jsonAttributes, + action: 'action' + }, + ActivityActions.CREATE, + options?.created_by + ); } } diff --git a/lib/casbin/index.ts b/lib/casbin/index.ts index 9c7897ce4..3ac54f276 100644 --- a/lib/casbin/index.ts +++ b/lib/casbin/index.ts @@ -8,6 +8,10 @@ import { JsonFilteredEnforcer } from './JsonFilteredEnforcer'; +const Config = require('../../config/config'); + +const baseDir = Config.get('/typeormDir').dir; + const { newWatcher } = CasbinPgWatcher; class CasbinSingleton { @@ -51,7 +55,8 @@ class CasbinSingleton { if (!this.policyAdapter) { this.policyAdapter = await TypeORMAdapter.newAdapter({ type: 'postgres', - url: dbConnectionUrl + url: dbConnectionUrl, + subscribers: [`${baseDir}/subscriber/*{.ts,.js}`] }); } diff --git a/migration/1614858387492-CreateActivityTable.ts b/migration/1614858387492-CreateActivityTable.ts new file mode 100644 index 000000000..436c24ba0 --- /dev/null +++ b/migration/1614858387492-CreateActivityTable.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateActivityTable1614858387492 implements MigrationInterface { + name = 'CreateActivityTable1614858387492'; + + public up = async (queryRunner: QueryRunner) => { + await queryRunner.query(` + create table activities ( + id uuid default uuid_generate_v4() not null constraint activities_pk primary key, + title character varying not null, + model character varying not null, + document_id character varying not null, + document jsonb, + diffs jsonb, + created_by jsonb, + created_at timestamp default now() not null ); + `); + }; + + public down = async (queryRunner: QueryRunner) => { + await queryRunner.query(`DROP TABLE "activities"`); + }; +} diff --git a/model/activity.ts b/model/activity.ts new file mode 100644 index 000000000..c81bdbc9a --- /dev/null +++ b/model/activity.ts @@ -0,0 +1,55 @@ +import { + Entity, + Column, + CreateDateColumn, + BaseEntity, + PrimaryGeneratedColumn +} from 'typeorm'; + +import Constants from '../utils/constant'; +import { User } from './user'; + +@Entity(Constants.MODEL.Activity) +export class Activity extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'varchar', + nullable: false + }) + title: string; + + @Column({ + type: 'varchar', + nullable: false + }) + model: string; + + @Column({ + type: 'varchar', + nullable: false + }) + documentId: string; + + @Column({ + type: 'jsonb', + nullable: false + }) + document: Record; + + @Column({ + type: 'jsonb', + nullable: true + }) + diffs: Record[]; + + @CreateDateColumn() + createdAt: string; + + @Column({ + type: 'jsonb', + nullable: false + }) + createdBy: User; +} diff --git a/model/group.ts b/model/group.ts index 9515a5171..2860c5e69 100644 --- a/model/group.ts +++ b/model/group.ts @@ -6,8 +6,9 @@ import { BaseEntity, PrimaryGeneratedColumn } from 'typeorm'; +import Constants from '../utils/constant'; -@Entity('groups') +@Entity(Constants.MODEL.Group) export class Group extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/model/role.ts b/model/role.ts index 4d288beec..b83043e6a 100644 --- a/model/role.ts +++ b/model/role.ts @@ -6,8 +6,9 @@ import { BaseEntity, PrimaryGeneratedColumn } from 'typeorm'; +import Constants from '../utils/constant'; -@Entity('roles') +@Entity(Constants.MODEL.Role) export class Role extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/model/user.ts b/model/user.ts index d0d7b775e..1f0134649 100644 --- a/model/user.ts +++ b/model/user.ts @@ -7,7 +7,9 @@ import { PrimaryGeneratedColumn } from 'typeorm'; -@Entity('users') +import Constants from '../utils/constant'; + +@Entity(Constants.MODEL.User) export class User extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/ormconfig.js b/ormconfig.js index dc59650f3..607213213 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,5 +1,6 @@ +const { SnakeNamingStrategy } = require('typeorm-naming-strategies'); const Config = require('./config/config'); -const {SnakeNamingStrategy} = require('typeorm-naming-strategies'); + const baseDir = Config.get('/typeormDir').dir; module.exports = { @@ -10,8 +11,9 @@ module.exports = { entities: [`${baseDir}/model/*{.ts,.js}`], migrations: [`${baseDir}/migration/*{.ts,.js}`], factories: [`${baseDir}/factory/*{.ts,.js}`], + subscribers: [`${baseDir}/subscriber/*{.ts,.js}`], cli: { migrationsDir: 'migration' }, - namingStrategy:new SnakeNamingStrategy() + namingStrategy: new SnakeNamingStrategy() }; diff --git a/package.json b/package.json index 63639ab47..5168f43cf 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "casbin": "^5.2.2", "casbin-pg-watcher": "^0.1.1", "confidence": "^5.0.0", + "deep-diff": "^1.0.2", "faker": "^5.1.0", "hapi-swagger": "^14.0.0", "joi": "^17.3.0", diff --git a/plugin/iam/responseHooks.ts b/plugin/iam/responseHooks.ts index 1186f16a8..c5f7dfeb4 100644 --- a/plugin/iam/responseHooks.ts +++ b/plugin/iam/responseHooks.ts @@ -9,7 +9,8 @@ import { constructIAMResourceFromConfig } from './utils'; export const upsertResourceAttributesMapping = async ( iamUpsertConfigList: IAMUpsertConfig[], - requestData: Record + requestData: Record, + user: Hapi.UserCredentials | undefined ) => { const { enforcer } = CasbinSingleton; @@ -24,9 +25,12 @@ export const upsertResourceAttributesMapping = async ( requestData ); if (!R.isEmpty(resource) && !R.isEmpty(resourceAttributes)) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return enforcer?.upsertResourceGroupingJsonPolicy( resource, - resourceAttributes + resourceAttributes, + { created_by: JSON.parse(JSON.stringify(user)) } ); } @@ -158,7 +162,7 @@ const manageResourceAttributesMapping = async ( h: Hapi.ResponseToolkit ) => { const route = server.match(request.method, request.path); - + const { user } = request.auth.credentials; const shouldTriggerHooks = checkIfShouldTriggerHooks(route, request); if (shouldTriggerHooks) { const statusCode = R.pathOr(200, ['response', 'statusCode'], request); @@ -169,7 +173,7 @@ const manageResourceAttributesMapping = async ( // only upsert for write based http methods if (isWriteMethod(request.method)) { - await upsertResourceAttributesMapping(hooks, requestData); + await upsertResourceAttributesMapping(hooks, requestData, user); } if (!R.isEmpty(requestData.response)) { diff --git a/subscriber/group.ts b/subscriber/group.ts new file mode 100644 index 000000000..758a07855 --- /dev/null +++ b/subscriber/group.ts @@ -0,0 +1,26 @@ +import { + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, + UpdateEvent +} from 'typeorm'; +import { + log as ActivityLog, + actions as ActivityActions +} from '../app/activity/resource'; +import { Group } from '../model/group'; + +@EventSubscriber() +export class GroupSubscriber implements EntitySubscriberInterface { + listenTo = () => { + return Group; + }; + + afterInsert = async (event: InsertEvent) => { + await ActivityLog(event, ActivityActions.CREATE); + }; + + afterUpdate = async (event: UpdateEvent) => { + await ActivityLog(event, ActivityActions.EDIT); + }; +} diff --git a/test/app/activity/handler.ts b/test/app/activity/handler.ts new file mode 100644 index 000000000..6fbfb4159 --- /dev/null +++ b/test/app/activity/handler.ts @@ -0,0 +1,60 @@ +import Code from 'code'; +import Lab from '@hapi/lab'; +import Hapi from '@hapi/hapi'; +import Sinon from 'sinon'; +import { factory } from 'typeorm-seeding'; +import { lab } from '../../setup'; +import * as Config from '../../../config/config'; +import * as activityPlugin from '../../../app/activity'; +import * as Resource from '../../../app/activity/resource'; +import { Activity } from '../../../model/activity'; + +exports.lab = Lab.script(); +let server: Hapi.Server; +const Sandbox = Sinon.createSandbox(); + +const TEST_AUTH = { + strategy: 'test', + credentials: { id: 'dev.test' } +}; + +lab.before(async () => { + const plugins = [activityPlugin]; + server = new Hapi.Server({ port: Config.get('/port/web'), debug: false }); + await server.register(plugins); +}); + +lab.after(async () => { + await server.stop(); +}); + +lab.afterEach(() => { + Sandbox.restore(); +}); + +lab.experiment('Activity::Handler', () => { + lab.experiment('get all activities', () => { + let request: any, getStub: any, activities: any; + + lab.beforeEach(async () => { + activities = await factory(Activity)().createMany(5); + getStub = Sandbox.stub(Resource, 'get'); + request = { + method: 'GET', + url: `/api/activities`, + auth: TEST_AUTH + }; + }); + lab.afterEach(() => { + getStub.restore(); + }); + + lab.test('should get user by id', async () => { + getStub.resolves(activities); + const response = await server.inject(request); + Sandbox.assert.calledWithExactly(getStub); + Code.expect(response.result).to.equal(activities); + Code.expect(response.statusCode).to.equal(200); + }); + }); +}); diff --git a/test/app/activity/resource.ts b/test/app/activity/resource.ts new file mode 100644 index 000000000..14f0e1195 --- /dev/null +++ b/test/app/activity/resource.ts @@ -0,0 +1,253 @@ +import Lab from '@hapi/lab'; +import Sinon from 'sinon'; +import Code from 'code'; +import * as Faker from 'faker'; +import { factory } from 'typeorm-seeding'; +import { lab } from '../../setup'; +import { Activity } from '../../../model/activity'; +import * as Resource from '../../../app/activity/resource'; +import { User } from '../../../model/user'; +import { delta } from '../../../utils/deep-diff'; + +exports.lab = Lab.script(); +const Sandbox = Sinon.createSandbox(); + +lab.afterEach(() => { + Sandbox.restore(); +}); + +lab.experiment('Activity::resource', () => { + lab.experiment('create activity', () => { + lab.afterEach(() => { + Sandbox.restore(); + }); + + lab.test('should create activity with delta', async () => { + const activity = { + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)', + diffs: [ + { rhs: 'p', kind: 'N', path: ['ptype'] }, + { + rhs: { group: '231d7c4c-d3c4-4bff-aacc-d7b0cf197edf' }, + kind: 'N', + path: ['subject'] + }, + { + rhs: { group: '231d7c4c-d3c4-4bff-aacc-d7b0cf197edf' }, + kind: 'N', + path: ['resource'] + }, + { + rhs: { role: 'd56e6288-6d67-43bc-8062-69bde9835312' }, + kind: 'N', + path: ['action'] + } + ], + document: {}, + documentId: 'ba256f55-bfce-4d17-8174-a0abbf26ccd4', + model: 'User', + title: 'Bypass Chips heuristic' + }; + const activityId = Faker.random.uuid(); + const activitySaveStub = Sandbox.stub(Activity, 'save').returns({ + id: activityId, + ...activity + }); + const response = await Resource.create(activity); + Sandbox.assert.calledWithExactly(activitySaveStub, activity); + Code.expect(response).to.equal({ id: activityId, ...activity }); + }); + + lab.test('should create activity without delta', async () => { + const activity = { + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)', + diffs: [], + document: {}, + documentId: 'ba256f55-bfce-4d17-8174-a0abbf26ccd4', + model: 'User', + title: 'Bypass Chips heuristic' + }; + const response = await Resource.create(activity); + Code.expect(response).to.equal(null); + }); + }); + + lab.experiment('log activity', () => { + lab.afterEach(() => { + Sandbox.restore(); + }); + + lab.test( + 'should log activity by typeorm subscriber after insert event', + async () => { + const user = await factory(User)().create(); + const activityId = Faker.random.uuid(); + const event = { + metadata: { + tableName: 'groups' + }, + entity: { + id: activityId, + displayname: 'Mock', + first: 'First', + second: 'Second', + metadata: { + key: 'value', + number: 1, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + queryRunner: { + data: { + user + } + } + }; + + const activity = { + document: {}, + documentId: '0', + diffs: delta({}, event.entity, { + exclude: ['createdAt', 'updatedAt'] + }), + title: 'Created Mock team ', + createdBy: user + }; + + Sandbox.stub(Activity, 'create').returns({ + id: activityId, + ...activity + }); + await Resource.log(event, Resource.actions.CREATE); + } + ); + + lab.test( + 'should log activity by typeorm subscriber after update event', + async () => { + const user = await factory(User)().create(); + const activityId = Faker.random.uuid(); + const event = { + metadata: { + tableName: 'groups' + }, + databaseEntity: { + id: activityId, + displayname: 'Mock', + first: 'First', + second: 'Second', + metadata: { + key: 'value', + number: 1, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + entity: { + id: activityId, + displayname: 'Mock', + first: 'First Edited', + second: 'Second', + metadata: { + key: 'value edited', + number: 2, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + queryRunner: { + data: { + user + } + } + }; + + const activity = { + document: { + id: activityId, + displayname: 'Mock', + first: 'First', + second: 'Second', + metadata: { + key: 'value', + number: 1, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + documentId: activityId, + diffs: delta(event.databaseEntity, event.entity, { + exclude: ['createdAt', 'updatedAt'] + }), + title: 'Edited Mock team ', + createdBy: user + }; + + Sandbox.stub(Activity, 'create').returns({ + id: activityId, + ...activity + }); + await Resource.log(event, Resource.actions.EDIT); + } + ); + + lab.test( + 'should log activity by typeorm subscriber after remove event', + async () => { + const user = await factory(User)().create(); + const activityId = Faker.random.uuid(); + const event = { + metadata: { + tableName: 'groups' + }, + databaseEntity: { + id: activityId, + displayname: 'Mock', + first: 'First', + second: 'Second', + metadata: { + key: 'value', + number: 1, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + entity: {}, + queryRunner: { + data: { + user + } + } + }; + + const activity = { + document: { + id: activityId, + displayname: 'Mock', + first: 'First', + second: 'Second', + metadata: { + key: 'value', + number: 1, + bool: true + }, + createdAt: 'Fri Mar 12 2021 13:24:53 GMT+0530 (India Standard Time)' + }, + documentId: activityId, + diffs: delta(event.databaseEntity, event.entity, { + exclude: ['createdAt', 'updatedAt'] + }), + title: 'Removed Mock team ', + createdBy: user + }; + + Sandbox.stub(Activity, 'create').returns({ + id: activityId, + ...activity + }); + await Resource.log(event, Resource.actions.DELETE); + } + ); + }); +}); diff --git a/test/app/group/resource.ts b/test/app/group/resource.ts index 08aafe1ef..febe834ea 100644 --- a/test/app/group/resource.ts +++ b/test/app/group/resource.ts @@ -118,9 +118,19 @@ lab.experiment('Group::resource', () => { ...payload }); - const loggedInUserId = Faker.random.uuid(); + const user = await factory(User)().create(); + const loggedInUserId = user.id; + + Sandbox.stub(User, 'findOne').resolves(user); + const response = await Resource.create(payload, loggedInUserId); + Sandbox.assert.calledWithExactly( + checkSubjectHasAccessToCreateAttributesMappingStub, + { user: loggedInUserId }, + payload.attributes + ); + Sandbox.assert.calledWithExactly( checkSubjectHasAccessToCreateAttributesMappingStub, { user: loggedInUserId }, @@ -129,11 +139,13 @@ lab.experiment('Group::resource', () => { Sandbox.assert.calledWithExactly( upsertGroupAndAttributesMappingStub, groupId, - payload.attributes + payload.attributes, + user ); Sandbox.assert.calledWithExactly( groupSaveStub, - R.omit(['attributes', 'policies'], payload) + R.omit(['attributes', 'policies'], payload), + { data: { user } } ); Sandbox.assert.calledWithExactly( bulkUpsertPoliciesForGroupStub, @@ -142,6 +154,7 @@ lab.experiment('Group::resource', () => { loggedInUserId ); Sandbox.assert.calledWithExactly(getStub, groupId, loggedInUserId); + Code.expect(response).to.equal({ id: groupId, ...payload, @@ -191,9 +204,11 @@ lab.experiment('Group::resource', () => { id: groupId, ...payload }; + const user = await factory(User)().create(); const getStub = Sandbox.stub(Resource, 'get').returns(group); + Sandbox.stub(User, 'findOne').returns(user); - const loggedInUserId = Faker.random.uuid(); + const loggedInUserId = user.id; const response = await Resource.update(groupId, payload, loggedInUserId); Sandbox.assert.calledWithExactly( @@ -204,7 +219,8 @@ lab.experiment('Group::resource', () => { ); Sandbox.assert.calledWithExactly( groupSaveStub, - R.omit(['attributes', 'policies'], { ...payload, id: groupId }) + R.omit(['attributes', 'policies'], { ...payload, id: groupId }), + { data: { user } } ); Sandbox.assert.calledWithExactly( bulkUpsertPoliciesForGroupStub, @@ -223,7 +239,7 @@ lab.experiment('Group::resource', () => { }); lab.experiment('get list of groups', () => { - let groups, users, currentUser; + let groups: any, users: any, currentUser: any; const sortByDisplayName = R.sortBy(R.propOr(null, 'displayname')); const removeExtraKeys = R.map(R.omit(['createdAt', 'updatedAt'])); @@ -243,7 +259,7 @@ lab.experiment('Group::resource', () => { sortMemberPolicies ); - const getMemberPolicy = (user, group, role) => { + const getMemberPolicy = (user: any, group: any, role: any) => { return { subject: { user: user.id }, resource: { group: group.id }, @@ -264,18 +280,22 @@ lab.experiment('Group::resource', () => { const getId = R.path(['id']); - const makeUserTeamAdmin = async (user, group) => { + const makeUserTeamAdmin = async (user: any, group: any) => { const groupId = getId(group); const userId = getId(user); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer?.addSubjectGroupingJsonPolicy( { user: userId }, - { group: groupId } + { group: groupId }, + { created_by: user } ); await enforcer?.addJsonPolicy( { user: userId }, { group: groupId }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); }; @@ -283,14 +303,20 @@ lab.experiment('Group::resource', () => { await Promise.all( R.take(3, groups).map(async (group) => { const groupId = getId(group); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer?.addResourceGroupingJsonPolicy( { group: groupId }, - { entity: 'gojek' } + { entity: 'gojek' }, + { created_by: currentUser } ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer?.addSubjectGroupingJsonPolicy( { user: getId(currentUser) }, - { group: groupId } + { group: groupId }, + { created_by: currentUser } ); }) ); @@ -298,9 +324,12 @@ lab.experiment('Group::resource', () => { // map 2 groups with gofin await Promise.all( R.takeLast(2, groups).map(async (group) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer?.addResourceGroupingJsonPolicy( { group: getId(group) }, - { entity: 'gofin' } + { entity: 'gofin' }, + { created_by: currentUser } ); }) ); diff --git a/test/app/group/user/handler.ts b/test/app/group/user/handler.ts index cf592be43..faf2ba3fa 100644 --- a/test/app/group/user/handler.ts +++ b/test/app/group/user/handler.ts @@ -200,7 +200,12 @@ lab.experiment('Group:User::Handler', () => { const response = await server.inject(request); - Sandbox.assert.calledWithExactly(removeStub, GROUP_ID, USER_ID); + Sandbox.assert.calledWithExactly( + removeStub, + GROUP_ID, + USER_ID, + TEST_AUTH.credentials.id + ); Code.expect(response.result).to.equal(expectedResult); Code.expect(response.statusCode).to.equal(200); }); @@ -233,6 +238,7 @@ lab.experiment('Group:User::Handler', () => { Sandbox.assert.calledWithExactly( removeStub, GROUP_ID, + TEST_AUTH.credentials.id, TEST_AUTH.credentials.id ); Code.expect(response.result).to.equal(expectedResult); diff --git a/test/app/group/user/resource.ts b/test/app/group/user/resource.ts index d287ffc14..7245add2a 100644 --- a/test/app/group/user/resource.ts +++ b/test/app/group/user/resource.ts @@ -1,6 +1,7 @@ import Lab from '@hapi/lab'; import Sinon from 'sinon'; import Code from 'code'; +import { factory } from 'typeorm-seeding'; import { lab } from '../../../setup'; import * as Resource from '../../../../app/group/user/resource'; import CasbinSingleton from '../../../../lib/casbin'; @@ -87,14 +88,15 @@ lab.experiment('Group:User:Mapping::resource', () => { addSubjectGroupingJsonPolicy: addSubjectGroupingJsonPolicyStub }); + const user = await factory(User)().create(); const groupId = 'test_group'; - const userId = 'test_user'; - const loggedInUserId = 'test_logged_in_user'; + const userId = user.id; + const loggedInUserId = user.id; const payload = { policies: [ { operation: 'create', - subject: { user: 'test_user' }, + subject: { user: user.id }, resource: { group: 'test_group', entity: 'gojek', @@ -212,6 +214,7 @@ lab.experiment('Group:User:Mapping::resource', () => { } ] }; + const user = await factory(User)().create(); const removeSubjectGroupingJsonPolicyStub = Sandbox.stub(); const removeJsonPolicyStub = Sandbox.stub(); Sandbox.stub(CasbinSingleton, 'enforcer').value({ @@ -224,7 +227,7 @@ lab.experiment('Group:User:Mapping::resource', () => { 'getPoliciesBySubject' ).returns(payload.policies); - const response = await Resource.remove(groupId, userId); + const response = await Resource.remove(groupId, userId, user.id); Sandbox.assert.calledWithExactly( getPoliciesBySubjectStub, @@ -234,7 +237,8 @@ lab.experiment('Group:User:Mapping::resource', () => { Sandbox.assert.calledWithExactly( removeSubjectGroupingJsonPolicyStub, { user: userId }, - { group: groupId } + { group: groupId }, + { created_by: user } ); Sandbox.assert.callCount(removeJsonPolicyStub, payload.policies.length); Code.expect(response).to.equal(true); @@ -249,6 +253,7 @@ lab.experiment('Group:User:Mapping::resource', () => { const payload: any = { policies: [] }; + const user = await factory(User)().create(); const removeSubjectGroupingJsonPolicyStub = Sandbox.stub(); const removeJsonPolicyStub = Sandbox.stub(); Sandbox.stub(CasbinSingleton, 'enforcer').value({ @@ -261,7 +266,7 @@ lab.experiment('Group:User:Mapping::resource', () => { 'getPoliciesBySubject' ).returns(payload.policies); - const response = await Resource.remove(groupId, userId); + const response = await Resource.remove(groupId, userId, user.id); Sandbox.assert.calledWithExactly( getPoliciesBySubjectStub, @@ -271,7 +276,8 @@ lab.experiment('Group:User:Mapping::resource', () => { Sandbox.assert.calledWithExactly( removeSubjectGroupingJsonPolicyStub, { user: userId }, - { group: groupId } + { group: groupId }, + { created_by: user } ); Sandbox.assert.notCalled(removeJsonPolicyStub); Code.expect(response).to.equal(true); diff --git a/test/app/user/group/resource.ts b/test/app/user/group/resource.ts index f7fd181a6..a6a65d178 100644 --- a/test/app/user/group/resource.ts +++ b/test/app/user/group/resource.ts @@ -23,7 +23,7 @@ lab.afterEach(() => { lab.experiment('UserGroup::resource', () => { lab.experiment('get groups of a user', () => { - let users, groups, enforcer; + let users: any, groups: any, enforcer: any; lab.before(async () => { const dbUri = Config.get('/postgres').uri; @@ -33,52 +33,62 @@ lab.experiment('UserGroup::resource', () => { lab.beforeEach(async () => { users = await factory(User)().createMany(2); groups = await factory(Group)().createMany(3); - + const user = users[0]; await enforcer.addResourceGroupingJsonPolicy( { group: groups[0].id }, - { entity: 'gojek' } + { entity: 'gojek' }, + { created_by: user } ); await enforcer.addResourceGroupingJsonPolicy( { group: groups[1].id }, - { entity: 'gojek' } + { entity: 'gojek' }, + { created_by: user } ); await enforcer.addResourceGroupingJsonPolicy( { group: groups[2].id }, - { entity: 'gofin' } + { entity: 'gofin' }, + { created_by: user } ); await enforcer.addSubjectGroupingJsonPolicy( { user: users[0].id }, - { group: groups[0].id } + { group: groups[0].id }, + { created_by: user } ); await enforcer.addSubjectGroupingJsonPolicy( { user: users[0].id }, - { group: groups[1].id } + { group: groups[1].id }, + { created_by: user } ); await enforcer.addSubjectGroupingJsonPolicy( { user: users[1].id }, - { group: groups[0].id } + { group: groups[0].id }, + { created_by: user } ); await enforcer.addActionGroupingJsonPolicy( { action: '*' }, - { role: 'entity.admin' } + { role: 'entity.admin' }, + { created_by: user } ); await enforcer.addJsonPolicy( { user: users[1].id }, { entity: 'gofin' }, - { role: 'entity.admin' } + { role: 'entity.admin' }, + { created_by: user } ); await enforcer.addJsonPolicy( { user: users[0].id }, { group: groups[0].id }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); await enforcer.addJsonPolicy( { user: users[1].id }, { group: groups[0].id }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); }); diff --git a/test/app/user/resource.ts b/test/app/user/resource.ts index 21b788dc9..2db96ad5c 100644 --- a/test/app/user/resource.ts +++ b/test/app/user/resource.ts @@ -37,7 +37,7 @@ lab.experiment('User::resource', () => { }); lab.experiment('list users', () => { - let users, groups, userEntityPolicy; + let users: any, groups, userEntityPolicy: any; lab.beforeEach(async () => { // setup data @@ -46,58 +46,80 @@ lab.experiment('User::resource', () => { users = await factory(User)().createMany(5); groups = await factory(Group)().createMany(2); + const user = users[0]; // user group mapping + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer.addSubjectGroupingJsonPolicy( { user: users[0].id }, - { group: groups[0].id } + { group: groups[0].id }, + { created_by: user } ); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer.addSubjectGroupingJsonPolicy( { user: users[1].id }, - { group: groups[0].id } + { group: groups[0].id }, + { created_by: user } ); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore await enforcer.addSubjectGroupingJsonPolicy( { user: users[3].id }, - { group: groups[1].id } + { group: groups[1].id }, + { created_by: user } ); - await enforcer.addSubjectGroupingJsonPolicy( + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await enforcer?.addSubjectGroupingJsonPolicy( { user: users[2].id }, - { group: groups[1].id } + { group: groups[1].id }, + { created_by: user } ); // create relavant policies - await enforcer.addJsonPolicy( + await enforcer?.addJsonPolicy( { group: groups[0].id }, { entity: 'gojek', privacy: 'public' }, - { action: 'firehose.read' } + { action: 'firehose.read' }, + { created_by: user } ); userEntityPolicy = { subject: { user: users[2].id }, resource: { entity: 'gojek' }, action: { action: 'firehose.read' } }; - await enforcer.addJsonPolicy( + await enforcer?.addJsonPolicy( userEntityPolicy.subject, userEntityPolicy.resource, - userEntityPolicy.action + userEntityPolicy.action, + { created_by: user } ); - await enforcer.addJsonPolicy( + await enforcer?.addJsonPolicy( { user: users[2].id }, { group: groups[1].id }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); }); - lab.test('should return all users if no filters are specifed', async () => { - const getListWithFiltersStub = Sandbox.stub( - Resource, - 'getListWithFilters' - ).returns([]); - - const result = await Resource.list(); - Code.expect(result).to.equal(users); - Sandbox.assert.notCalled(getListWithFiltersStub); - }); + lab.test( + 'should return all users if no filters are specified', + async () => { + const getListWithFiltersStub = Sandbox.stub( + Resource, + 'getListWithFilters' + ).returns(users); + + const result = await Resource.list(); + Code.expect(result).to.equal(users); + Sandbox.assert.notCalled(getListWithFiltersStub); + } + ); lab.test('should return users that match the filter', async () => { const removeTimestamps = R.omit(['createdAt', 'updatedAt']); diff --git a/test/lib/casbin/sample.ts b/test/lib/casbin/sample.ts index 77380b1c5..612488bd7 100644 --- a/test/lib/casbin/sample.ts +++ b/test/lib/casbin/sample.ts @@ -1,6 +1,15 @@ +import { factory } from 'typeorm-seeding'; import CasbinSingleton from '../../../lib/casbin'; +import { User } from '../../../model/user'; +const users: any[] = []; +const createUser = async () => { + const user = await factory(User)().create(); + users.push(user); + return user; +}; const setupPolicies = async () => { + const user = await createUser(); await CasbinSingleton.enforcer.addJsonPolicy( { user: 'alice' }, { @@ -9,7 +18,8 @@ const setupPolicies = async () => { environment: 'production', team: 'transport' }, - { role: 'resource.manager' } + { role: 'resource.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -17,7 +27,8 @@ const setupPolicies = async () => { { entity: 'gojek' }, - { role: 'dwh.manager' } + { role: 'dwh.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -28,7 +39,8 @@ const setupPolicies = async () => { environment: 'production', team: 'augur' }, - { role: 'resource.manager' } + { role: 'resource.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -36,7 +48,8 @@ const setupPolicies = async () => { { team: 'transport' }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -44,7 +57,8 @@ const setupPolicies = async () => { { entity: 'gojek' }, - { role: 'entity.admin' } + { role: 'entity.admin' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -52,7 +66,8 @@ const setupPolicies = async () => { { team: 'transport' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -60,7 +75,8 @@ const setupPolicies = async () => { { team: 'augur' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -68,7 +84,8 @@ const setupPolicies = async () => { { team: 'gofinance' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -77,7 +94,8 @@ const setupPolicies = async () => { entity: 'gojek', privacy: 'public' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -86,7 +104,8 @@ const setupPolicies = async () => { entity: 'gojek', privacy: 'public' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addJsonPolicy( @@ -95,44 +114,56 @@ const setupPolicies = async () => { entity: 'gofin', privacy: 'public' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addStrPolicy( JSON.stringify({ team: 'de' }), '*', - JSON.stringify({ role: 'super.admin' }) + JSON.stringify({ role: 'super.admin' }), + { created_by: user } ); }; const setupUserTeamMapping = async () => { + const user = await createUser(); + await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'alice' }, - { team: 'transport' } + { team: 'transport' }, + { created_by: user } ); await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'bob' }, - { team: 'transport' } + { team: 'transport' }, + { created_by: user } ); await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'dave' }, - { team: 'augur' } + { team: 'augur' }, + { created_by: user } ); await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'frank' }, - { team: 'augur' } + { team: 'augur' }, + { created_by: user } ); await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'ele' }, - { team: 'gofinance' } + { team: 'gofinance' }, + { created_by: user } ); await CasbinSingleton.enforcer.addSubjectGroupingJsonPolicy( { user: 'gary' }, - { team: 'de' } + { team: 'de' }, + { created_by: user } ); }; const setupResourceProjectMapping = async () => { + const user = await createUser(); + await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( { resource: 'p-gojek-id-firehose-transport-123' @@ -143,7 +174,8 @@ const setupResourceProjectMapping = async () => { landscape: 'id', team: 'transport', privacy: 'public' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -156,7 +188,8 @@ const setupResourceProjectMapping = async () => { landscape: 'id', team: 'augur', privacy: 'public' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -169,7 +202,8 @@ const setupResourceProjectMapping = async () => { landscape: 'id', team: 'augur', privacy: 'private' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -181,7 +215,8 @@ const setupResourceProjectMapping = async () => { environment: 'production', landscape: 'id', privacy: 'public' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -194,18 +229,21 @@ const setupResourceProjectMapping = async () => { landscape: 'id', privacy: 'public', team: 'gofinance' - } + }, + { created_by: user } ); }; const setupTeamEntityProjectMapping = async () => { + const user = await createUser(); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( { team: 'augur' }, { entity: 'gojek' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -214,7 +252,8 @@ const setupTeamEntityProjectMapping = async () => { }, { entity: 'gojek' - } + }, + { created_by: user } ); await CasbinSingleton.enforcer.addResourceGroupingJsonPolicy( @@ -223,27 +262,33 @@ const setupTeamEntityProjectMapping = async () => { }, { entity: 'gofin' - } + }, + { created_by: user } ); }; const setupPermissionRoleMapping = async () => { + const user = await createUser(); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: '*' }, - { role: 'team.admin' } + { role: 'team.admin' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: '*' }, - { role: 'entity.admin' } + { role: 'entity.admin' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: '*' }, - { role: 'super.admin' } + { role: 'super.admin' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'firehose.read' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'dagger.read' }, @@ -251,25 +296,30 @@ const setupPermissionRoleMapping = async () => { ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'beast.read' }, - { role: 'resource.viewer' } + { role: 'resource.viewer' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'firehose.write' }, - { role: 'resource.manager' } + { role: 'resource.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'dagger.write' }, - { role: 'resource.manager' } + { role: 'resource.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { role: 'resource.viewer' }, - { role: 'resource.manager' } + { role: 'resource.manager' }, + { created_by: user } ); await CasbinSingleton.enforcer.addActionGroupingJsonPolicy( { action: 'beast.*' }, - { role: 'dwh.manager' } + { role: 'dwh.manager' }, + { created_by: user } ); }; @@ -279,4 +329,7 @@ export default async () => { await setupResourceProjectMapping(); await setupTeamEntityProjectMapping(); await setupPermissionRoleMapping(); + return { + users + }; }; diff --git a/test/lib/casbin/scenarios.ts b/test/lib/casbin/scenarios.ts index 0f64891bc..9fedd8e0e 100644 --- a/test/lib/casbin/scenarios.ts +++ b/test/lib/casbin/scenarios.ts @@ -1,16 +1,20 @@ import Code from 'code'; import Lab from '@hapi/lab'; +import { factory } from 'typeorm-seeding'; import * as Config from '../../../config/config'; import setupSampleData from './sample'; import CasbinSingleton from '../../../lib/casbin'; import { lab } from '../../setup'; import connection from '../../connection'; +import { User } from '../../../model/user'; exports.lab = Lab.script(); const testScenarios = () => { + let users: any[]; lab.beforeEach(async () => { - await setupSampleData(); + const response = await setupSampleData(); + users = response.users; }); lab.test( @@ -47,6 +51,11 @@ const testScenarios = () => { const obj = { resource: 'p-gojek-id-firehose-transport-123' }; + const user = await factory(User)().create(); + user.id = 'alice'; + user.username = 'alice'; + user.displayname = 'alice'; + const options = { created_by: user }; const act = { action: 'firehose.write' }; const res = await context.enforcer.enforceJson(sub, obj, act); Code.expect(res).to.equal(true); diff --git a/test/plugin/iam/responseHooks.ts b/test/plugin/iam/responseHooks.ts index 1d6630ebc..89a4def2b 100644 --- a/test/plugin/iam/responseHooks.ts +++ b/test/plugin/iam/responseHooks.ts @@ -177,7 +177,7 @@ lab.experiment('ResponseHooks::upsertResourceAttributesMapping', () => { } }; - await upsertResourceAttributesMapping(iamUpsertConfig, requestData); + await upsertResourceAttributesMapping(iamUpsertConfig, requestData, {}); Sandbox.assert.called(constructIAMResourceFromConfigStub); // TODO: Check why this is not stubbing @@ -187,7 +187,13 @@ lab.experiment('ResponseHooks::upsertResourceAttributesMapping', () => { }); lab.experiment('ResponseHooks::mergeResourceListWithAttributes', () => { - let getResourceAttributeMappingsByResourcesStub; + let getResourceAttributeMappingsByResourcesStub: + | Sinon.SinonStub< + [resources?: Record[] | undefined], + Promise<{ resource: any; attributes: any }[]> + > + | Sinon.SinonSpy<[{ name: string }[]], any> + | Sinon.SinonSpyCall<[{ name: string }[]], any>; const hook = { resources: [{ name: { key: 'name', type: 'response' } }], attributes: [ diff --git a/utils/constant.ts b/utils/constant.ts new file mode 100644 index 000000000..206f7ed58 --- /dev/null +++ b/utils/constant.ts @@ -0,0 +1,9 @@ +export default { + MODEL: { + Activity: 'activities', + Group: 'groups', + Role: 'roles', + User: 'users', + CasbinRule: 'casbin_rule' + } +}; diff --git a/utils/deep-diff.ts b/utils/deep-diff.ts new file mode 100644 index 000000000..97836fd08 --- /dev/null +++ b/utils/deep-diff.ts @@ -0,0 +1,13 @@ +const { diff } = require('deep-diff'); + +export const delta = ( + previous = {}, + current = {}, + options?: { exclude: string[] } +) => { + return diff(previous, current).filter((i: any) => { + return (options?.exclude || []).every( + (x: any) => i.path && i.path.indexOf(x) === -1 + ); + }); +}; diff --git a/yarn.lock b/yarn.lock index 3940d6ff2..ebd32b699 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1703,6 +1703,11 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" +deep-diff@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26" + integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"