Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Audit users.update API endpoint #34494

Draft
wants to merge 11 commits into
base: develop
Choose a base branch
from
61 changes: 39 additions & 22 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { Filter } from 'mongodb';

import { auditUserChangeByUser } from '../../../../server/lib/auditServerEvents/userChanged';
import { i18n } from '../../../../server/lib/i18n';
import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey';
import { sendWelcomeEmail } from '../../../../server/lib/sendWelcomeEmail';
Expand Down Expand Up @@ -95,35 +96,51 @@ API.v1.addRoute(
{ authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST },
{
async post() {
const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data };
return auditUserChangeByUser(async (asyncStore) => {
const store = asyncStore.getStore();

store?.setActor({
_id: this.bodyParams.userId,
ip: this.requestIp,
useragent: this.request.headers['user-agent'] || '',
username: (await Meteor.userAsync())?.username || '',
});

if (userData.name && !validateNameChars(userData.name)) {
return API.v1.failure('Name contains invalid characters');
}
const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data };

await saveUser(this.userId, userData);
if (userData.name && !validateNameChars(userData.name)) {
return API.v1.failure('Name contains invalid characters');
}

if (this.bodyParams.data.customFields) {
await saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields);
}
await saveUser(this.userId, userData);

if (typeof this.bodyParams.data.active !== 'undefined') {
const {
userId,
data: { active },
confirmRelinquish,
} = this.bodyParams;
// TODO: Audit this
if (this.bodyParams.data.customFields) {
await saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields);
}

await Meteor.callAsync('setUserActiveStatus', userId, active, Boolean(confirmRelinquish));
}
const { fields } = await this.parseJsonQuery();
// TODO: Audit this
if (typeof this.bodyParams.data.active !== 'undefined') {
const {
userId,
data: { active },
confirmRelinquish,
} = this.bodyParams;

const user = await Users.findOneById(this.bodyParams.userId, { projection: fields });
if (!user) {
return API.v1.failure('User not found');
}
await Meteor.callAsync('setUserActiveStatus', userId, active, Boolean(confirmRelinquish));
// store?.insertBoth({ active }, { active });
}
const { fields } = await this.parseJsonQuery();

return API.v1.success({ user });
const user = await Users.findOneById(this.bodyParams.userId, { projection: fields });

if (!user) {
return API.v1.failure('User not found');
}
// store?.insertCurrent({ customFields: user?.customFields });

return API.v1.success({ user });
});
},
},
);
Expand Down
10 changes: 9 additions & 1 deletion apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { saveNewUser } from './saveNewUser';
import { sendPasswordEmail } from './sendUserEmail';
import { validateUserData } from './validateUserData';
import { validateUserEditing } from './validateUserEditing';
import { asyncLocalStorage } from '../../../../../server/lib/auditServerEvents/userChanged';

export type SaveUserData = {
_id?: IUser['_id'];
Expand Down Expand Up @@ -73,10 +74,14 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser
delete userData.setRandomPassword;
}

if (!isUpdateUserData(userData)) {
if (!isUpdateUserData(userData) || !oldUserData) {
// TODO audit new users
return saveNewUser(userData, sendPassword);
}

const store = asyncLocalStorage.getStore();
store?.setOriginalUser(oldUserData);

await validateUserEditing(userId, userData);

// update user
Expand Down Expand Up @@ -146,6 +151,9 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser

// App IPostUserUpdated event hook
const userUpdated = await Users.findOneById(userData._id);
if (userUpdated) {
store?.setCurrentUser(userUpdated);
}

await callbacks.run('afterSaveUser', {
user: userUpdated,
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default {
'<rootDir>/ee/server/patches/**/*.spec.ts',
'<rootDir>/app/cloud/server/functions/supportedVersionsToken/**.spec.ts',
'<rootDir>/app/utils/lib/**.spec.ts',
'<rootDir>/server/lib/auditServerEvents/**.spec.ts',
],
},
],
Expand Down
236 changes: 236 additions & 0 deletions apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { faker } from '@faker-js/faker';
import type { IAuditServerUserActor, IUser } from '@rocket.chat/core-typings';

import { UserChangedLogStore } from './userChanged';
import { createFakeUser } from '../../../tests/mocks/data';

const createChangedUserAndActor = (
changes: Partial<IUser>,
overrides?: Partial<IUser>,
): [IUser, IUser, Omit<IAuditServerUserActor, 'type'>] => {
const originalUser = createFakeUser(overrides);
const currentUser: IUser = {
...originalUser,
...changes,
};

const actor: Omit<IAuditServerUserActor, 'type'> = {
ip: 'actorIp',
useragent: 'actorUserAgent',
_id: 'actorId',
username: 'actorUsername',
};

return [originalUser, currentUser, actor];
};

const createEmailsField = (address?: string, verified = true) => {
return {
emails: [
{
address: address || faker.internet.email(),
verified,
},
],
};
};

const createObfuscatedFields = (_2faEnabled = true): Pick<IUser, 'services' | 'e2e' | 'oauth'> => {
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: {
password: '***',
email2fa: email2faState,
email: '***',
resume: '***',
},
});

describe('userChanged audit module', () => {
it('should build event with only name and username fields', async () => {
const store = new UserChangedLogStore();

const [originalUser, currentUser, actor] = createChangedUserAndActor(
{
username: 'newUsername',
name: 'newName',
},
{ ...createEmailsField() },
);

store.setOriginalUser(originalUser as IUser);
store.setCurrentUser(currentUser as IUser);
store.setActor(actor);

const event = store.buildEvent();

expect(event).toEqual([
'user.changed',
{
user: { _id: currentUser._id, username: currentUser.username },
previous: { username: originalUser.username, name: originalUser.name },
current: { username: currentUser.username, name: currentUser.name },
},
{
ip: 'actorIp',
useragent: 'actorUserAgent',
_id: 'actorId',
username: 'actorUsername',
type: 'user',
},
]);
});
it('should build event with only emails field', async () => {
const store = new UserChangedLogStore();

const [originalUser, currentUser, actor] = createChangedUserAndActor(
{
...createEmailsField(),
},
{ ...createEmailsField() },
);

store.setOriginalUser(originalUser as IUser);
store.setCurrentUser(currentUser as IUser);
store.setActor(actor);

const event = store.buildEvent();

expect(event).toEqual([
'user.changed',
{
user: { _id: currentUser._id, username: currentUser.username },
previous: { emails: originalUser.emails },
current: { emails: currentUser.emails },
},
{
ip: 'actorIp',
useragent: 'actorUserAgent',
_id: 'actorId',
username: 'actorUsername',
type: 'user',
},
]);
});
it('should build event with every changed field', async () => {
const store = new UserChangedLogStore();

const [originalUser, currentUser, actor] = createChangedUserAndActor(
{
...createFakeUser(),
...createEmailsField(),
type: 'bot',
active: true,
roles: ['user', 'bot'],
},
{ ...createEmailsField(), active: false },
);

store.setOriginalUser(originalUser as IUser);
store.setCurrentUser(currentUser as IUser);
store.setActor(actor);

const event = store.buildEvent();

expect(event).toEqual([
'user.changed',
{
user: { _id: currentUser._id, username: currentUser.username },
previous: originalUser,
current: currentUser,
},
{
ip: 'actorIp',
useragent: 'actorUserAgent',
_id: 'actorId',
username: 'actorUsername',
type: 'user',
},
]);
});
it('should obfuscate sensitive fields', async () => {
const store = new UserChangedLogStore();

const [originalUser, currentUser, actor] = createChangedUserAndActor(
{
...createFakeUser(),
...createEmailsField(),
...createObfuscatedFields(true),
type: 'bot',
active: true,
roles: ['user', 'bot'],
},
{ ...createEmailsField(), ...createObfuscatedFields(false), active: false },
);

store.setOriginalUser(originalUser as IUser);
store.setCurrentUser(currentUser as IUser);
store.setActor(actor);

const event = store.buildEvent();

expect(event).toEqual([
'user.changed',
{
user: { _id: currentUser._id, username: currentUser.username },
previous: { ...originalUser, ...getObfuscatedFields(originalUser.services?.email2fa as any) },
current: { ...currentUser, ...getObfuscatedFields(currentUser.services?.email2fa as any) },
},
{
ip: 'actorIp',
useragent: 'actorUserAgent',
_id: 'actorId',
username: 'actorUsername',
type: 'user',
},
]);
});
});
Loading
Loading