From bb4ff0db3dcedcc715eb4b69b3f8d5c79ce0cb5f Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:54:11 -0300 Subject: [PATCH] feat: `audit.settings` endpoint (#35258) Co-authored-by: Lucas Pelegrino <16467257+lucas-a-pelegrino@users.noreply.github.com> --- .changeset/spicy-kangaroos-hear.md | 6 + apps/meteor/app/api/server/index.ts | 3 + .../meteor/app/api/server/v1/server-events.ts | 104 ++++++++++++++++++ apps/meteor/tests/end-to-end/api/settings.ts | 97 +++++++++++++++- packages/rest-typings/src/index.ts | 3 + .../ServerEventsAuditSettingsParamsGET.ts | 84 ++++++++++++++ .../src/v1/server-events/index.ts | 2 + .../src/v1/server-events/server-events.ts | 12 ++ 8 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 .changeset/spicy-kangaroos-hear.md create mode 100644 apps/meteor/app/api/server/v1/server-events.ts create mode 100644 packages/rest-typings/src/v1/server-events/ServerEventsAuditSettingsParamsGET.ts create mode 100644 packages/rest-typings/src/v1/server-events/index.ts create mode 100644 packages/rest-typings/src/v1/server-events/server-events.ts diff --git a/.changeset/spicy-kangaroos-hear.md b/.changeset/spicy-kangaroos-hear.md new file mode 100644 index 00000000000..3a9be696661 --- /dev/null +++ b/.changeset/spicy-kangaroos-hear.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Introduces `/v1/audit.settings` endpoint for querying changed settings audit events diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 1d036306679..e4f6370bfb0 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -48,6 +48,9 @@ import './v1/voip/omnichannel'; import './v1/voip'; import './v1/federation'; import './v1/moderation'; +import './v1/server-events'; + +// This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/apps/meteor/app/api/server/v1/server-events.ts b/apps/meteor/app/api/server/v1/server-events.ts new file mode 100644 index 00000000000..59fe33d41e5 --- /dev/null +++ b/apps/meteor/app/api/server/v1/server-events.ts @@ -0,0 +1,104 @@ +import { ServerEvents } from '@rocket.chat/models'; +import { isServerEventsAuditSettingsProps } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; + +import { API } from '../api'; +import { getPaginationItems } from '../helpers/getPaginationItems'; + +API.v1.get( + 'audit.settings', + { + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + events: { + type: 'array', + items: { + type: 'object', + }, + }, + count: { + type: 'number', + description: 'The number of events returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of events that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of events that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['events', 'count', 'offset', 'total', 'success'], + }), + 400: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [false], + }, + error: { + type: 'string', + }, + errorType: { + type: 'string', + }, + }, + required: ['success', 'error'], + }), + }, + query: isServerEventsAuditSettingsProps, + authRequired: true, + permissionsRequired: ['can-audit'], + }, + async function action() { + const { start, end, settingId, actor } = this.queryParams; + + if (start && isNaN(Date.parse(start as string))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + + if (end && isNaN(Date.parse(end as string))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort } = await this.parseJsonQuery(); + const _sort = { ts: sort?.ts ? sort?.ts : -1 }; + + const { cursor, totalCount } = ServerEvents.findPaginated( + { + ...(settingId && { 'data.key': 'id', 'data.value': settingId }), + ...(actor && { actor }), + ts: { + $gte: start ? new Date(start as string) : new Date(0), + $lte: end ? new Date(end as string) : new Date(), + }, + t: 'settings.changed', + }, + { + sort: _sort, + skip: offset, + limit: count, + allowDiskUse: true, + }, + ); + + const [events, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + events, + count: events.length, + offset, + total, + }); + }, +); diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts index 0873190b7ba..6cd635e41e8 100644 --- a/apps/meteor/tests/end-to-end/api/settings.ts +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -1,4 +1,4 @@ -import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { IServerEvents, LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, it, after } from 'mocha'; @@ -273,4 +273,99 @@ describe('[Settings]', () => { .end(done); }); }); + + describe('/audit.settings', () => { + const formatDate = (date: Date) => date.toISOString().slice(0, 10).replace(/-/g, '/'); + + it('should return list of settings changed (no filters)', async () => { + void request + .get(api('audit.settings')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + }); + }); + + it('should return list of settings between date ranges', async () => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(startDate.getDate() + 1); + + void request + .get(api('audit.settings')) + .query({ start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + }); + }); + + it('should throw error when sending invalid dates', async () => { + const startDate = new Date(); + const endDate = '2025/01'; + + void request + .get(api('audit.settings')) + .query({ start: formatDate(startDate), end: endDate }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('message', 'The "start" query parameter must be a valid date.'); + }); + }); + + it('should return list of settings changed filtered by an actor', async () => { + void request + .get(api('audit.settings')) + .query({ actor: { type: 'user' } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + }); + }); + + it('should return list of changes of an specific setting', async () => { + void request + .get(api('audit.settings')) + .query({ settingId: 'Site_Url' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + res.body.events.find( + (event: IServerEvents['settings.changed']) => event.data[0].key === 'id' && event.data[0].value === 'Site_Url', + ); + }); + }); + + it('should return list of changes of an specific setting filtered by an actor between date ranges', async () => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(startDate.getDate() + 1); + + void request + .get(api('audit.settings')) + .query({ actor: { type: 'user' }, start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + }); + }); + }); }); diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index c3d5721493a..9c466272f63 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -40,6 +40,7 @@ import type { PresenceEndpoints } from './v1/presence'; import type { PushEndpoints } from './v1/push'; import type { RolesEndpoints } from './v1/roles'; import type { RoomsEndpoints } from './v1/rooms'; +import type { ServerEventsEndpoints } from './v1/server-events'; import type { SettingsEndpoints } from './v1/settings'; import type { StatisticsEndpoints } from './v1/statistics'; import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints'; @@ -101,6 +102,7 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, VoipFreeSwitchEndpoints, + ServerEventsEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -253,6 +255,7 @@ export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; export * from './v1/moderation'; +export * from './v1/server-events'; export * from './v1/autotranslate/AutotranslateGetSupportedLanguagesParamsGET'; export * from './v1/autotranslate/AutotranslateSaveSettingsParamsPOST'; diff --git a/packages/rest-typings/src/v1/server-events/ServerEventsAuditSettingsParamsGET.ts b/packages/rest-typings/src/v1/server-events/ServerEventsAuditSettingsParamsGET.ts new file mode 100644 index 00000000000..3aff39373ad --- /dev/null +++ b/packages/rest-typings/src/v1/server-events/ServerEventsAuditSettingsParamsGET.ts @@ -0,0 +1,84 @@ +import type { IAuditServerActor } from '@rocket.chat/core-typings'; +import Ajv from 'ajv'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type ServerEventsAuditSettingsParamsGET = PaginatedRequest<{ + start?: string; + end?: string; + settingId?: string; + actor?: IAuditServerActor; +}>; + +const ServerEventsAuditSettingsParamsGetSchema = { + type: 'object', + properties: { + sort: { + type: 'object', + nullable: true, + properties: { + ts: { + type: 'number', + nullable: true, + }, + }, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + start: { + type: 'string', + nullable: true, + }, + end: { + type: 'string', + nullable: true, + }, + settingId: { + type: 'string', + nullable: true, + }, + actor: { + type: 'object', + nullable: true, + properties: { + type: { + type: 'string', + nullable: true, + }, + _id: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + ip: { + type: 'string', + nullable: true, + }, + useragent: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + }, + }, + }, + additionalProperties: false, +}; + +export const isServerEventsAuditSettingsProps = ajv.compile(ServerEventsAuditSettingsParamsGetSchema); diff --git a/packages/rest-typings/src/v1/server-events/index.ts b/packages/rest-typings/src/v1/server-events/index.ts new file mode 100644 index 00000000000..81a8d432639 --- /dev/null +++ b/packages/rest-typings/src/v1/server-events/index.ts @@ -0,0 +1,2 @@ +export * from './ServerEventsAuditSettingsParamsGET'; +export * from './server-events'; diff --git a/packages/rest-typings/src/v1/server-events/server-events.ts b/packages/rest-typings/src/v1/server-events/server-events.ts new file mode 100644 index 00000000000..518a5227b12 --- /dev/null +++ b/packages/rest-typings/src/v1/server-events/server-events.ts @@ -0,0 +1,12 @@ +import type { IServerEvents } from '@rocket.chat/core-typings'; + +import type { ServerEventsAuditSettingsParamsGET } from './ServerEventsAuditSettingsParamsGET'; +import type { PaginatedResult } from '../../helpers/PaginatedResult'; + +export type ServerEventsEndpoints = { + '/v1/audit.settings': { + GET: (params: ServerEventsAuditSettingsParamsGET) => PaginatedResult<{ + events: IServerEvents['settings.changed'][]; + }>; + }; +};