diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index fd6ee27a94..e6b3bc0427 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -5,7 +5,7 @@ import EventService from '../../services/event-service'; import { ADMIN, NONE } from '../../types/permissions'; import { IEvent, IEventList } from '../../types/events'; import Controller from '../controller'; -import { anonymise } from '../../util/anonymise'; +import { anonymiseKeys } from '../../util/anonymise'; import { OpenApiService } from '../../services/openapi-service'; import { createResponseSchema } from '../../openapi/util/create-response-schema'; import { endpointDescriptions } from '../../openapi/endpoint-descriptions'; @@ -23,6 +23,7 @@ import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import { IFlagResolver } from '../../types/experimental'; +const ANON_KEYS = ['email', 'username', 'createdBy']; const version = 1; export default class EventController extends Controller { private eventService: EventService; @@ -108,24 +109,7 @@ export default class EventController extends Controller { maybeAnonymiseEvents(events: IEvent[]): IEvent[] { if (this.flagResolver.isEnabled('anonymiseEventLog')) { - return events.map((e: IEvent) => ({ - ...e, - createdBy: anonymise(e.createdBy), - data: - e.data && 'email' in e.data - ? { - ...e.data, - email: anonymise(e.data.email), - } - : e.data, - preData: - e.preData && 'email' in e.preData - ? { - ...e.preData, - email: anonymise(e.preData.email), - } - : e.preData, - })); + return anonymiseKeys(events, ANON_KEYS); } return events; } diff --git a/src/lib/routes/admin-api/events.test.ts b/src/lib/routes/admin-api/events.test.ts index 7bf10d7a6b..7e999e6240 100644 --- a/src/lib/routes/admin-api/events.test.ts +++ b/src/lib/routes/admin-api/events.test.ts @@ -7,6 +7,7 @@ import createStores from '../../../test/fixtures/store'; import getApp from '../../app'; import { FeatureCreatedEvent, + ProjectAccessAddedEvent, ProjectUserAddedEvent, ProjectUserRemovedEvent, } from '../../types/events'; @@ -104,3 +105,33 @@ test('should also anonymise email fields in data and preData properties', async expect(body.events[0].data.email).not.toBe(email1); expect(body.events[1].preData.email).not.toBe(email2); }); + +test('should anonymise any PII fields, no matter the depth', async () => { + const testUsername = 'test-username'; + + const { request, base, eventStore } = await getSetup(true); + eventStore.store( + new ProjectAccessAddedEvent({ + createdBy: 'some@email.com', + data: { + groups: [ + { + name: 'test', + project: 'default', + users: [{ username: testUsername }], + }, + ], + }, + project: 'default', + }), + ); + const { body } = await request + .get(`${base}/api/admin/events`) + .expect('Content-Type', /json/) + .expect(200); + + expect(body.events.length).toBe(1); + expect(body.events[0].data.groups[0].users[0].username).not.toBe( + testUsername, + ); +}); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 2b380f9570..144df1602e 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -35,6 +35,7 @@ import { ProjectUserUpdateRoleEvent, RoleName, IFlagResolver, + ProjectAccessAddedEvent, } from '../types'; import { IProjectQuery, @@ -348,6 +349,7 @@ export default class ProjectService { }; } + // Deprecated: See addAccess instead. async addUser( projectId: string, roleId: number, @@ -492,13 +494,25 @@ export default class ProjectService { usersAndGroups: IProjectAccessModel, createdBy: string, ): Promise { - return this.accessService.addAccessToProject( + await this.accessService.addAccessToProject( usersAndGroups.users, usersAndGroups.groups, projectId, roleId, createdBy, ); + + await this.eventStore.store( + new ProjectAccessAddedEvent({ + project: projectId, + createdBy, + data: { + roleId, + groups: usersAndGroups.groups.map(({ id }) => id), + users: usersAndGroups.users.map(({ id }) => id), + }, + }), + ); } async findProjectGroupRole( diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index e55719d98c..c5a973da0c 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -40,6 +40,7 @@ export const DROP_STRATEGIES = 'drop-strategies'; export const CONTEXT_FIELD_CREATED = 'context-field-created'; export const CONTEXT_FIELD_UPDATED = 'context-field-updated'; export const CONTEXT_FIELD_DELETED = 'context-field-deleted'; +export const PROJECT_ACCESS_ADDED = 'project-access-added'; export const PROJECT_CREATED = 'project-created'; export const PROJECT_UPDATED = 'project-updated'; export const PROJECT_DELETED = 'project-deleted'; @@ -563,7 +564,7 @@ export class ProjectUserUpdateRoleEvent extends BaseEvent { data: any; preData: any; }) { - super(PROJECT_USER_REMOVED, eventData.createdBy); + super(PROJECT_USER_ROLE_CHANGED, eventData.createdBy); const { project, data, preData } = eventData; this.project = project; this.data = data; @@ -637,6 +638,25 @@ export class ProjectGroupUpdateRoleEvent extends BaseEvent { } } +export class ProjectAccessAddedEvent extends BaseEvent { + readonly project: string; + + readonly data: any; + + readonly preData: any; + + /** + * @param createdBy accepts a string for backward compatibility. Prefer using IUser for standardization + */ + constructor(p: { project: string; createdBy: string | IUser; data: any }) { + super(PROJECT_ACCESS_ADDED, p.createdBy); + const { project, data } = p; + this.project = project; + this.data = data; + this.preData = null; + } +} + export class SettingCreatedEvent extends BaseEvent { readonly data: any; diff --git a/src/lib/util/anonymise.ts b/src/lib/util/anonymise.ts index a502c86be3..af8acf124c 100644 --- a/src/lib/util/anonymise.ts +++ b/src/lib/util/anonymise.ts @@ -7,3 +7,26 @@ export function anonymise(s: string): string { .slice(0, 9); return `${hash}@unleash.run`; } + +export function anonymiseKeys(object: T, keys: string[]): T { + if (typeof object !== 'object' || object === null) { + return object; + } + + if (Array.isArray(object)) { + return object.map((item) => anonymiseKeys(item, keys)) as unknown as T; + } else { + return Object.keys(object).reduce((result, key) => { + if ( + keys.includes(key) && + result[key] !== undefined && + result[key] !== null + ) { + result[key] = anonymise(result[key]); + } else if (typeof result[key] === 'object') { + result[key] = anonymiseKeys(result[key], keys); + } + return result; + }, object); + } +}