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 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 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,49 @@ 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;
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();
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
30 changes: 29 additions & 1 deletion apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 @@ -48,7 +49,10 @@ export type SaveUserData = {
};

export const saveUser = async function (userId: IUser['_id'], userData: SaveUserData) {
const oldUserData = userData._id && (await Users.findOneById(userData._id));
const oldUserData = userData._id ? await Users.findOneById(userData._id) : undefined;

const auditStore = asyncLocalStorage.getStore();

if (oldUserData && isUserFederated(oldUserData)) {
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user');
}
Expand All @@ -67,6 +71,7 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser
userData.password = generatePassword();
userData.requirePasswordChange = true;
sendPassword = true;
auditStore?.insertBoth({ password: '**********' }, { password: 'random' });
}

delete userData.setRandomPassword;
Expand All @@ -78,6 +83,9 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser

await validateUserEditing(userId, userData as RequiredField<SaveUserData, '_id'>);

auditStore?.setUser({ _id: userData._id, username: oldUserData?.username });
auditStore?.insertPrevious({ customFields: oldUserData?.customFields });

// update user
if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) {
if (
Expand All @@ -92,15 +100,21 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser
method: 'saveUser',
});
}
auditStore?.insertBoth(
{ username: oldUserData?.username, name: oldUserData?.name },
{ username: userData.username, name: userData.name },
);
}

if (typeof userData.statusText === 'string') {
await setStatusText(userData._id, userData.statusText);
auditStore?.insertBoth({ statusText: oldUserData?.statusText }, { statusText: userData.statusText });
}

if (userData.email) {
const shouldSendVerificationEmailToUser = userData.verified !== true;
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser);
auditStore?.insertBoth({ emails: oldUserData?.emails }, { emails: [{ address: userData.email, verified: userData.verified }] });
}

if (
Expand All @@ -109,6 +123,9 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser
passwordPolicy.validate(userData.password)
) {
await Accounts.setPasswordAsync(userData._id, userData.password.trim());
if (!sendPassword) {
auditStore?.insertBoth({ password: '**********' }, { password: 'manual' });
}
} else {
sendPassword = false;
}
Expand All @@ -119,21 +136,32 @@ export const saveUser = async function (userId: IUser['_id'], userData: SaveUser
};

handleBio(updateUser, userData.bio);
auditStore?.insertBoth({ bio: oldUserData?.bio }, { bio: userData.bio });

handleNickname(updateUser, userData.nickname);
auditStore?.insertBoth({ nickname: oldUserData?.nickname }, { nickname: userData.nickname });

if (userData.roles) {
updateUser.$set.roles = userData.roles;
auditStore?.insertBoth({ roles: oldUserData?.roles }, { roles: userData.roles });
}

if (userData.settings) {
updateUser.$set.settings = { preferences: userData.settings.preferences };
auditStore?.insertBoth({ settings: oldUserData?.settings }, { settings: { ...oldUserData?.settings, ...userData.settings } } as any);
}

if (userData.language) {
updateUser.$set.language = userData.language;
auditStore?.insertBoth({ language: oldUserData?.language }, { language: userData.language });
}

if (typeof userData.requirePasswordChange !== 'undefined') {
updateUser.$set.requirePasswordChange = userData.requirePasswordChange;
auditStore?.insertBoth(
{ requirePasswordChange: oldUserData?.requirePasswordChange },
{ requirePasswordChange: userData.requirePasswordChange },
);
if (!userData.requirePasswordChange) {
updateUser.$unset.requirePasswordChangeReason = 1;
}
Expand Down
138 changes: 138 additions & 0 deletions apps/meteor/server/lib/auditServerEvents/userChanged.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { AsyncLocalStorage } from 'async_hooks';

import type {
IAuditServerUserActor,
IServerEvents,
IServerEventAuditedUser,
ExtractDataToParams,
IUser,
DeepPartial,
} from '@rocket.chat/core-typings';
import { ServerEvents } from '@rocket.chat/models';

export const asyncLocalStorage = new AsyncLocalStorage<UserChangedLogStore>();

type AuditedUserEventData<T extends keyof IServerEventAuditedUser = keyof IServerEventAuditedUser> = {
[K in T]: {
key: K;
value: {
previous?: IServerEventAuditedUser[K];
current?: IServerEventAuditedUser[K];
};
};
}[T];

type UserChangeMap = Map<AuditedUserEventData['key'], AuditedUserEventData>;

const isServerEventData = (data: AuditedUserEventData['value']): data is Required<AuditedUserEventData['value']> => {
return data.hasOwnProperty('previous') && data.hasOwnProperty('current') && Object.keys(data).length === 2;
};

type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];

class UserChangedLogStore {
private changes: UserChangeMap;

private changedUser: Pick<IUser, '_id' | 'username'> | undefined;

private actor: IAuditServerUserActor;

private actorType: IAuditServerUserActor['type'];

constructor(type: IAuditServerUserActor['type'] = 'user') {
this.changes = new Map();
this.actorType = type;
}

public setActor(actor: Omit<IAuditServerUserActor, 'type'>) {
this.actor = { ...actor, type: this.actorType };
}

public setUser(user: Pick<IUser, '_id' | 'username'>) {
this.changedUser = user;
}

private insertChangeRecords(_: undefined, record: Partial<IServerEventAuditedUser>): void;

private insertChangeRecords(record: DeepPartial<IServerEventAuditedUser>, _: undefined): void;

private insertChangeRecords(...args: [prev?: DeepPartial<IServerEventAuditedUser>, curr?: Partial<IServerEventAuditedUser>]): void {
const denominator = args[0] ? 'previous' : 'current';
const record = args[0] || args[1];

if (!record) return;

(Object.entries(record) as Entries<IServerEventAuditedUser>).forEach((entry) => {
if (!entry) return;
const [key, recordValue] = entry;

const property = this.changes.get(key);
if (property) {
const value = {
...property.value,
[denominator]: recordValue,
};
this.changes.set(key, { key, value } as AuditedUserEventData);
return;
}
const value = {
[denominator]: recordValue,
};
this.changes.set(key, { value, key } as AuditedUserEventData);
});
}

private getServerEventData(): ExtractDataToParams<IServerEvents['user.changed']> {
const filtered = Array.from(this.changes.entries()).filter(
([, { value }]) => isServerEventData(value) && value.previous !== value.current,
);

const data = filtered.reduce(
(acc, [key, { value }]) => {
return {
previous: { ...acc.previous, ...{ [key]: value.previous } },
current: { ...acc.current, ...{ [key]: value.current } },
};
},
{ previous: {}, current: {} },
);

return Object.assign(data, { user: { _id: this.changedUser?._id || '', username: this.changedUser?.username } });
}

public insertPrevious(previous: DeepPartial<IServerEventAuditedUser>) {
this.insertChangeRecords(previous, undefined);
}

public insertCurrent(current: Partial<IServerEventAuditedUser>) {
this.insertChangeRecords(undefined, current);
}

public insertBoth(previous: Partial<IServerEventAuditedUser>, current: Partial<IServerEventAuditedUser>) {
this.insertPrevious(previous);
this.insertCurrent(current);
}

public buildEvent(): ['user.changed', ExtractDataToParams<IServerEvents['user.changed']>, IAuditServerUserActor] {
return ['user.changed', this.getServerEventData(), this.actor];
}
}

export const auditUserChangeByUser = <T extends (store: typeof asyncLocalStorage, ...args: any[]) => any>(
fn: T,
): Promise<ReturnType<T>> => {
const store = new UserChangedLogStore();

return new Promise<ReturnType<typeof fn>>((resolve) => {
asyncLocalStorage.run(store, () => {
void fn(asyncLocalStorage)
.then(resolve)
.finally(() => {
const event = store.buildEvent();
void ServerEvents.createAuditServerEvent(...event);
});
});
});
};
34 changes: 34 additions & 0 deletions packages/core-typings/src/ServerAudit/IAuditUserChangedEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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: 'previous';
value: Partial<DeepPartial<IServerEventAuditedUser>>;
}
| {
key: 'current';
value: Partial<IServerEventAuditedUser>;
}
> {
t: 'user.changed';
}

declare module '../IServerEvent' {
interface IServerEvents {
'user.changed': IServerEventUserChanged;
}
}
2 changes: 2 additions & 0 deletions packages/core-typings/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import './ServerAudit/IAuditServerSettingEvent';
import './ServerAudit/IAuditUserChangedEvent';

export * from './ServerAudit/IAuditUserChangedEvent';
export * from './Apps';
export * from './AppOverview';
export * from './FeaturedApps';
Expand Down
Loading