diff --git a/.changeset/slow-ravens-tie.md b/.changeset/slow-ravens-tie.md new file mode 100644 index 0000000000000..8714790f332bd --- /dev/null +++ b/.changeset/slow-ravens-tie.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/models": minor +--- + +Implements auditing events for `/v1/users.update` API endpoint diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 3018d3fa7d6ea..926ae3415d4a9 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -28,6 +28,7 @@ import type { Filter } from 'mongodb'; import { generatePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/generateToken'; import { regeneratePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/regenerateToken'; import { removePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/removeToken'; +import { UserChangedAuditStore } from '../../../../server/lib/auditServerEvents/userChanged'; import { i18n } from '../../../../server/lib/i18n'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { sendWelcomeEmail } from '../../../../server/lib/sendWelcomeEmail'; @@ -114,8 +115,14 @@ API.v1.addRoute( if (userData.name && !validateNameChars(userData.name)) { return API.v1.failure('Name contains invalid characters'); } + const auditStore = new UserChangedAuditStore({ + _id: this.user._id, + ip: this.requestIp, + useragent: this.request.headers['user-agent'] || '', + username: this.user.username || '', + }); - await saveUser(this.userId, userData); + await saveUser(this.userId, userData, { auditStore }); if (typeof this.bodyParams.data.active !== 'undefined') { const { @@ -123,9 +130,9 @@ API.v1.addRoute( data: { active }, confirmRelinquish, } = this.bodyParams; - await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); } + const { fields } = await this.parseJsonQuery(); const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts index 00baff3135d58..13884b6e34b9b 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -1,4 +1,5 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; +import { MeteorError } from '@rocket.chat/core-services'; import { isUserFederated } from '@rocket.chat/core-typings'; import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; @@ -7,6 +8,7 @@ import type { ClientSession } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; import { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../../../server/database/utils'; +import type { UserChangedAuditStore } from '../../../../../server/lib/auditServerEvents/userChanged'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { safeGetMeteorUser } from '../../../../utils/server/functions/safeGetMeteorUser'; import { generatePassword } from '../../lib/generatePassword'; @@ -50,12 +52,17 @@ export type SaveUserData = { sendWelcomeEmail?: boolean; customFields?: Record; + active?: boolean; }; export type UpdateUserData = RequiredField; export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id; +type SaveUserOptions = { + auditStore?: UserChangedAuditStore; +}; + const _saveUser = (session?: ClientSession) => - async function (userId: IUser['_id'], userData: SaveUserData) { + async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) { const oldUserData = userData._id && (await Users.findOneById(userData._id)); if (oldUserData && isUserFederated(oldUserData)) { throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); @@ -81,10 +88,18 @@ const _saveUser = (session?: ClientSession) => } if (!isUpdateUserData(userData)) { - // pass session? + // TODO audit new users return saveNewUser(userData, sendPassword); } + if (!oldUserData) { + throw new MeteorError('error-user-not-found', 'User not found', { + method: 'saveUser', + }); + } + + options?.auditStore?.setOriginalUser(oldUserData); + await validateUserEditing(userId, userData); // update user @@ -164,6 +179,16 @@ const _saveUser = (session?: ClientSession) => await Users.updateFromUpdater({ _id: userData._id }, updater, { session }); await onceTransactionCommitedSuccessfully(async () => { + if (session && options?.auditStore) { + // setting this inside here to avoid moving `executeSetUserActiveStatus` from the endpoint fn + // updater will be commited by this point, so it won't affect the external user activation/deactivation + if (userData.active !== undefined) { + updater.set('active', userData.active); + } + options.auditStore.setUpdateFilter(updater.getRawUpdateFilter()); + void options.auditStore.commitAuditEvent(); + } + // App IPostUserUpdated event hook // We need to pass the session here to ensure this record is fetched // with the uncommited transaction data. @@ -209,5 +234,9 @@ export const saveUser = (() => { if (!process.env.DEBUG_DISABLE_USER_AUDIT) { return wrapInSessionTransaction(_saveUser); } - return _saveUser(); + + const saveUserNoSession = _saveUser(); + return function saveUser(userId: IUser['_id'], userData: SaveUserData, _options?: any) { + return saveUserNoSession(userId, userData); + }; })(); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 5173506d9492b..c3a578ef18fe1 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -38,6 +38,7 @@ export default { '/ee/server/patches/**/*.spec.ts', '/app/cloud/server/functions/supportedVersionsToken/**.spec.ts', '/app/utils/lib/**.spec.ts', + '/server/lib/auditServerEvents/**.spec.ts', '/app/api/server/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', ], diff --git a/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts b/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts new file mode 100644 index 0000000000000..4d293b61152f8 --- /dev/null +++ b/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts @@ -0,0 +1,297 @@ +import { faker } from '@faker-js/faker'; +import type { IAuditServerUserActor, IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { UpdaterImpl } from '@rocket.chat/models'; + +import { UserChangedAuditStore } from './userChanged'; +import { createFakeUser } from '../../../tests/mocks/data'; + +const makeFakeActor = (): Omit => { + return { + ip: faker.internet.ip(), + useragent: faker.internet.userAgent(), + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + }; +}; + +const createUserAndUpdater = (overrides?: Partial): [IUser, Updater, Omit] => { + const originalUser = createFakeUser(overrides); + const updater = new UpdaterImpl(); + + const actor = makeFakeActor(); + + return [originalUser, updater, actor]; +}; + +const createEmailsField = (address?: string, verified = true) => { + return { + emails: [ + { + address: address || faker.internet.email(), + verified, + }, + ], + }; +}; + +jest.mock('@rocket.chat/models', () => { + return { + UpdaterImpl: jest.requireActual('@rocket.chat/models').UpdaterImpl, + ServerEvents: { + createAuditServerEvent: (...args: any) => args, + }, + }; +}); + +const createObfuscatedFields = (_2faEnabled = true): Pick => { + return { + services: { + password: { + bcrypt: faker.string.uuid(), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore this field is not in IUser, but is present in DB + enroll: { + token: faker.string.uuid(), + email: faker.internet.email(), + when: faker.date.past(), + reason: 'enroll', + }, + }, + email2fa: { + enabled: _2faEnabled, + changedAt: faker.date.past(), + }, + email: { + verificationTokens: [ + { + token: faker.string.uuid(), + address: faker.internet.email(), + when: faker.date.past(), + }, + ], + }, + resume: { + loginTokens: [ + { + when: faker.date.past(), + hashedToken: faker.string.uuid(), + twoFactorAuthorizedHash: faker.string.uuid(), + twoFactorAuthorizedUntil: faker.date.past(), + }, + ], + }, + }, + e2e: { + private_key: faker.string.uuid(), + public_key: faker.string.uuid(), + }, + inviteToken: faker.string.uuid(), + oauth: { authorizedClients: [faker.string.uuid(), faker.string.uuid()] }, + }; +}; + +const getObfuscatedFields = (email2faState: { enabled: boolean; changedAt: Date }) => ({ + e2e: '****', + oauth: '****', + inviteToken: '****', + services: getObfuscatedServices(email2faState), +}); + +const getObfuscatedServices = (email2faState: { enabled: boolean; changedAt: Date }) => { + return { + password: '****', + email2fa: email2faState, + email: '****', + resume: '****', + }; +}; + +describe('userChanged audit module', () => { + it('should build event with only name and username fields', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() }); + + const store = new UserChangedAuditStore(actor); + + const [newUsername, newName] = [faker.internet.userName(), faker.person.fullName()]; + + updater.set('username', newUsername); + updater.set('name', newName); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { username: user.username, name: user.name }, + operation: { $set: { username: newUsername, name: newName } }, + }, + { ...actor, type: 'user' }, + ]); + }); + + it('should build event with only emails field', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() }); + + const store = new UserChangedAuditStore(actor); + + const newEmailsField = createEmailsField(); + + updater.set('emails', newEmailsField.emails); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + operation: { $set: { ...newEmailsField } }, + user_data: { emails: user.emails }, + }, + { ...actor, type: 'user' }, + ]); + }); + + it('should build event with every changed field', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), active: false }); + + const changes = { + ...createFakeUser(), + ...createEmailsField(), + type: 'bot', + active: true, + }; + + Object.entries(changes).forEach(([key, value]: any) => { + updater.set(key, value); + }); + + updater.addToSet('roles', 'user'); + updater.addToSet('roles', 'bot'); + + const store = new UserChangedAuditStore(actor); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: user, + operation: { + $set: { + ...changes, + }, + $addToSet: { + roles: { $each: ['user', 'bot'] }, + }, + }, + }, + { + ...actor, + type: 'user', + }, + ]); + }); + it('should obfuscate sensitive fields', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + const changes = { + ...createFakeUser(), + ...createEmailsField(), + ...createObfuscatedFields(true), + type: 'bot', + active: true, + }; + + Object.entries(changes).forEach(([key, value]: any) => { + updater.set(key, value); + }); + + updater.addToSet('roles', 'user'); + updater.addToSet('roles', 'bot'); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { ...user, ...getObfuscatedFields(user.services?.email2fa as any) }, + operation: { + $set: { + ...changes, + ...getObfuscatedFields(changes.services?.email2fa as any), + }, + $addToSet: { + roles: { $each: ['user', 'bot'] }, + }, + }, + }, + { + ...actor, + type: 'user', + }, + ]); + }); + it('should obfuscate nested services', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + updater.set('services.password.bcrypt', faker.string.uuid()); + updater.set('services.resume.loginTokens', faker.string.uuid()); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { services: { password: '****', resume: '****' } }, + operation: { $set: { 'services.password.bcrypt': '****', 'services.resume.loginTokens': '****' } }, + }, + { ...actor, type: 'user' }, + ]); + }); + it('should obfuscate all services when they are set at once', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + updater.set('services', { password: { bcrypt: faker.string.uuid() } }); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { services: getObfuscatedServices(user.services?.email2fa as any) }, + operation: { $set: { services: { password: '****' } } }, + }, + { ...actor, type: 'user' }, + ]); + }); +}); diff --git a/apps/meteor/server/lib/auditServerEvents/userChanged.ts b/apps/meteor/server/lib/auditServerEvents/userChanged.ts new file mode 100644 index 0000000000000..ea71794952da8 --- /dev/null +++ b/apps/meteor/server/lib/auditServerEvents/userChanged.ts @@ -0,0 +1,168 @@ +import type { IAuditServerUserActor, IServerEvents, ExtractDataToParams, IUser } from '@rocket.chat/core-typings'; +import { ServerEvents } from '@rocket.chat/models'; +import type { UpdateFilter } from 'mongodb'; + +const userKeysToObfuscate = ['authorizedClients', 'e2e', 'inviteToken', 'oauth']; +const nestableKeysToObfuscate = ['services', 'password', 'bcrypt']; // ex: services.password.bcrypt + +const obfuscateServices = (services: Record): Record => { + return Object.fromEntries( + Object.keys(services).map((key) => { + // Email 2FA is okay, only tells if it's enabled + if (key === 'email2fa') { + return [key, services[key]]; + } + return [key, '****']; + }), + ); +}; +export class UserChangedAuditStore { + private originalUser: Partial | undefined; + + private updateFilter: UpdateFilter | undefined; + + private actor: IAuditServerUserActor; + + constructor(actor: Omit, type: IAuditServerUserActor['type'] = 'user') { + this.actor = { ...actor, type }; + } + + public setOriginalUser(user: Partial) { + this.originalUser = user; + } + + public setUpdateFilter(updateFilter: UpdateFilter) { + this.updateFilter = Object.fromEntries( + Object.entries(updateFilter).map(([key, value]) => { + const obfuscatedValue = Object.entries(value).reduce((acc, [k, v]) => { + if (userKeysToObfuscate.includes(k)) { + return { + ...acc, + [k]: '****', + }; + } + + // In case all services are set at once, we need to obfuscate them + if (k === 'services') { + return { + ...acc, + [k]: obfuscateServices(v as Record), + }; + } + + if (nestableKeysToObfuscate.some((key) => k.includes(key))) { + return { + ...acc, + [k]: '****', + }; + } + + return { ...acc, [k]: v }; + }, {}); + + return [key, obfuscatedValue]; + }), + ); + } + + private filterUserChangedProperties(originalUser: Partial, updateFilter: UpdateFilter): Partial { + if (Object.keys(updateFilter).length === 0) { + return {}; + } + + // extract keys from updateFilter (keys are nested in $set, $unset, $inc, etc) + const updateFilterKeys: string[] = Object.values(updateFilter).reduce((acc, current) => { + const keys = Object.keys(current); + if (keys.length === 0) { + return acc; + } + return [...acc, ...keys]; + }, []); + + return Object.entries(originalUser).reduce((acc, [key, value]) => { + if (!updateFilterKeys.some((k) => k.includes(key))) { + return acc; + } + + if (userKeysToObfuscate.includes(key)) { + return { + ...acc, + [key]: '****', + }; + } + + if (key === 'services') { + // In case all services are set at once we should + // obfuscate all user services, because they'll all change + if (updateFilterKeys.some((k) => k === 'services')) { + return { + ...acc, + [key]: obfuscateServices(value as Record), + }; + } + + const changedNestedServices = updateFilterKeys + .filter((k) => k.includes(key) && k.includes('.')) + .map((serviceKey) => { + // service key can be nested with dot notation + // ex: services.password.bcrypt + const serviceKeyParts = serviceKey.split('.'); + return [serviceKeyParts[1], value[serviceKey as keyof typeof value]]; + }) + .filter(Boolean); + + if (!changedNestedServices.length) { + return acc; + } + + return { + ...acc, + [key]: obfuscateServices(Object.fromEntries(changedNestedServices) as Record), + }; + } + + return { + ...acc, + [key]: value, + }; + }, {}); + } + + private getEventData( + originalUser: Partial, + updateFilter: UpdateFilter, + ): ExtractDataToParams { + const userData = this.filterUserChangedProperties(originalUser, updateFilter); + + return { + user: { _id: originalUser._id || '', username: originalUser.username }, + user_data: userData, + operation: updateFilter, + }; + } + + private buildEvent(): ['user.changed', ExtractDataToParams, IAuditServerUserActor] { + if (!this.updateFilter) { + throw new Error('UserChangedAuditStore - Updater is undefined'); + } + + if (!this.originalUser) { + throw new Error('UserChangedAuditStore - OriginalUser is undefined'); + } + + const eventData = this.getEventData(this.originalUser, this.updateFilter); + + if (Object.keys(eventData.user_data).length === 0 || Object.keys(eventData.operation).length === 0) { + // UpdaterImpl throws an error when trying to build the filter if no changes are detected + // so we should never get here + throw new Error('UserChangedAuditStore - No changes detected'); + } + + return ['user.changed', eventData, this.actor]; + } + + public async commitAuditEvent() { + const event = this.buildEvent(); + return ServerEvents.createAuditServerEvent(...event); + } +} diff --git a/packages/core-typings/src/ServerAudit/IAuditUserChangedEvent.ts b/packages/core-typings/src/ServerAudit/IAuditUserChangedEvent.ts new file mode 100644 index 0000000000000..4218dcbb7c311 --- /dev/null +++ b/packages/core-typings/src/ServerAudit/IAuditUserChangedEvent.ts @@ -0,0 +1,36 @@ +import type { UpdateFilter } from 'mongodb'; + +import type { IAuditServerEventType } from '../IServerEvent'; +import type { IUser } from '../IUser'; +import type { DeepPartial } from '../utils'; + +export type IServerEventAuditedUser = IUser & { + password: string; +}; + +interface IServerEventUserChanged + extends IAuditServerEventType< + | { + key: 'user'; + value: { + _id: IUser['_id']; + username: IUser['username']; + }; + } + | { + key: 'user_data'; + value: DeepPartial; + } + | { + key: 'operation'; + value: UpdateFilter; + } + > { + t: 'user.changed'; +} + +declare module '../IServerEvent' { + interface IServerEvents { + 'user.changed': IServerEventUserChanged; + } +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 6908b126aa435..5cb20c24ef859 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -1,5 +1,7 @@ import './ServerAudit/IAuditServerSettingEvent'; +import './ServerAudit/IAuditUserChangedEvent'; +export * from './ServerAudit/IAuditUserChangedEvent'; export * from './Apps'; export * from './AppOverview'; export * from './FeaturedApps'; diff --git a/packages/model-typings/src/updater.ts b/packages/model-typings/src/updater.ts index 7743430ad4b8b..d9ec9a84f57bf 100644 --- a/packages/model-typings/src/updater.ts +++ b/packages/model-typings/src/updater.ts @@ -8,6 +8,7 @@ export interface Updater { addToSet>(key: K, value: ArrayElementType[K]>): Updater; hasChanges(): boolean; getUpdateFilter(): UpdateFilter; + getRawUpdateFilter(): UpdateFilter; } type ArrayElementType = T extends (infer E)[] ? E : T; diff --git a/packages/models/src/updater.ts b/packages/models/src/updater.ts index 4f7ad271f397d..9babf5b5be20f 100644 --- a/packages/models/src/updater.ts +++ b/packages/models/src/updater.ts @@ -46,7 +46,7 @@ export class UpdaterImpl implements Updater { } hasChanges() { - const filter = this._getUpdateFilter(); + const filter = this.getRawUpdateFilter(); return this._hasChanges(filter); } @@ -54,7 +54,7 @@ export class UpdaterImpl implements Updater { return Object.keys(filter).length > 0; } - private _getUpdateFilter() { + public getRawUpdateFilter() { return { ...(this._set && { $set: Object.fromEntries(this._set) }), ...(this._unset && { $unset: Object.fromEntries([...this._unset.values()].map((k) => [k, 1])) }), @@ -68,7 +68,7 @@ export class UpdaterImpl implements Updater { throw new Error('Updater is dirty'); } this.dirty = true; - const filter = this._getUpdateFilter(); + const filter = this.getRawUpdateFilter(); if (!this._hasChanges(filter)) { throw new Error('No changes to update'); }