From b338807d76fb4d4bb4bf0e30ac389bea762507e2 Mon Sep 17 00:00:00 2001 From: Gustavo Reis Bauer Date: Thu, 17 Oct 2024 19:56:08 -0300 Subject: [PATCH] feat!: Implement Cloud communication reliability (#32856) --- .changeset/plenty-hairs-camp.md | 9 +++ apps/meteor/app/api/server/v1/misc.ts | 5 +- .../functions/getWorkspaceAccessToken.ts | 43 ++++++------ .../removeWorkspaceRegistrationInfo.ts | 4 +- .../server/functions/saveRegistrationData.ts | 55 +++++++-------- .../ee/server/models/WorkspaceCredentials.ts | 6 ++ .../server/models/raw/WorkspaceCredentials.ts | 68 +++++++++++++++++++ apps/meteor/ee/server/models/startup.ts | 1 + apps/meteor/server/settings/setup-wizard.ts | 22 ------ .../meteor/server/startup/migrations/index.ts | 1 + apps/meteor/server/startup/migrations/v316.ts | 31 +++++++++ .../src/ee/IWorkspaceCredentials.ts | 8 +++ packages/core-typings/src/index.ts | 1 + packages/model-typings/src/index.ts | 1 + .../src/models/IWorkspaceCredentialsModel.ts | 11 +++ packages/models/src/index.ts | 2 + 16 files changed, 190 insertions(+), 78 deletions(-) create mode 100644 .changeset/plenty-hairs-camp.md create mode 100644 apps/meteor/ee/server/models/WorkspaceCredentials.ts create mode 100644 apps/meteor/ee/server/models/raw/WorkspaceCredentials.ts create mode 100644 apps/meteor/server/startup/migrations/v316.ts create mode 100644 packages/core-typings/src/ee/IWorkspaceCredentials.ts create mode 100644 packages/model-typings/src/models/IWorkspaceCredentialsModel.ts diff --git a/.changeset/plenty-hairs-camp.md b/.changeset/plenty-hairs-camp.md new file mode 100644 index 000000000000..e75743f4a863 --- /dev/null +++ b/.changeset/plenty-hairs-camp.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/core-typings": major +"@rocket.chat/model-typings": major +"@rocket.chat/models": major +--- + +Adds a new collection to store all the workspace cloud tokens to defer the race condition management to MongoDB instead of having to handle it within the settings cache. +Removes the Cloud_Workspace_Access_Token & Cloud_Workspace_Access_Token_Expires_At settings since they are not going to be used anymore. \ No newline at end of file diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 349dbe824bb2..5cd522d20533 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; import { isOAuthUser, type IUser } from '@rocket.chat/core-typings'; -import { Settings, Users } from '@rocket.chat/models'; +import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models'; import { isShieldSvgProps, isSpotlightProps, @@ -664,6 +664,7 @@ API.v1.addRoute( const settingsIds: string[] = []; if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await WorkspaceCredentials.unsetCredentialByScope(); settingsIds.push( 'Cloud_Service_Agree_PrivacyTerms', 'Cloud_Workspace_Id', @@ -675,9 +676,7 @@ API.v1.addRoute( 'Cloud_Workspace_PublicKey', 'Cloud_Workspace_License', 'Cloud_Workspace_Had_Trial', - 'Cloud_Workspace_Access_Token', 'uniqueID', - 'Cloud_Workspace_Access_Token_Expires_At', ); } diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts index 1ea20812c062..93cfa3266ecf 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts @@ -1,15 +1,21 @@ -import { Settings } from '@rocket.chat/models'; +import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import { WorkspaceCredentials } from '@rocket.chat/models'; -import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; -import { settings } from '../../../settings/server'; import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +const hasWorkspaceAccessTokenExpired = (credentials: IWorkspaceCredentials): boolean => new Date() >= credentials.expirationDate; + /** - * @param {boolean} forceNew - * @param {string} scope - * @param {boolean} save - * @returns string + * Returns the access token for the workspace, if it is expired or forceNew is true, it will get a new one + * and save it to the database, therefore if this function does not throw an error, it will always return a valid token. + * + * @param {boolean} forceNew - If true, it will get a new token even if the current one is not expired + * @param {string} scope - The scope of the token to get + * @param {boolean} save - If true, it will save the new token to the database + * @throws {CloudWorkspaceAccessTokenError} If the workspace is not registered (no credentials in the database) + * + * @returns string - A valid access token for the workspace */ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true, throwOnError = false): Promise { const { workspaceRegistered } = await retrieveRegistrationStatus(); @@ -18,26 +24,23 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return ''; } - const expires = await Settings.findOneById('Cloud_Workspace_Access_Token_Expires_At'); - - if (expires === null) { - throw new Error('Cloud_Workspace_Access_Token_Expires_At is not set'); + const workspaceCredentials = await WorkspaceCredentials.getCredentialByScope(scope); + if (!workspaceCredentials) { + throw new CloudWorkspaceAccessTokenError(); } - const now = new Date(); - - if (expires.value && now < expires.value && !forceNew) { - return settings.get('Cloud_Workspace_Access_Token'); + if (!hasWorkspaceAccessTokenExpired(workspaceCredentials) && !forceNew) { + return workspaceCredentials.accessToken; } const accessToken = await getWorkspaceAccessTokenWithScope(scope, throwOnError); if (save) { - (await Settings.updateValueById('Cloud_Workspace_Access_Token', accessToken.token)).modifiedCount && - void notifyOnSettingChangedById('Cloud_Workspace_Access_Token'); - - (await Settings.updateValueById('Cloud_Workspace_Access_Token_Expires_At', accessToken.expiresAt)).modifiedCount && - void notifyOnSettingChangedById('Cloud_Workspace_Access_Token_Expires_At'); + await WorkspaceCredentials.updateCredentialByScope({ + scope, + accessToken: accessToken.token, + expirationDate: accessToken.expiresAt, + }); } return accessToken.token; diff --git a/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts b/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts index 45e1738e11e6..bf2b5d085945 100644 --- a/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts +++ b/apps/meteor/app/cloud/server/functions/removeWorkspaceRegistrationInfo.ts @@ -1,4 +1,4 @@ -import { Settings } from '@rocket.chat/models'; +import { Settings, WorkspaceCredentials } from '@rocket.chat/models'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; @@ -9,6 +9,8 @@ export async function removeWorkspaceRegistrationInfo() { return true; } + await WorkspaceCredentials.removeAllCredentials(); + const settingsIds = [ 'Cloud_Workspace_Id', 'Cloud_Workspace_Name', diff --git a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts index d92273302442..746904687d67 100644 --- a/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/saveRegistrationData.ts @@ -1,10 +1,22 @@ import { applyLicense } from '@rocket.chat/license'; -import { Settings } from '@rocket.chat/models'; +import { Settings, WorkspaceCredentials } from '@rocket.chat/models'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { syncCloudData } from './syncWorkspace/syncCloudData'; +type SaveRegistrationDataDTO = { + workspaceId: string; + client_name: string; + client_id: string; + client_secret: string; + client_secret_expires_at: number; + publicKey: string; + registration_client_uri: string; +}; + +type ManualSaveRegistrationDataDTO = SaveRegistrationDataDTO & { licenseData: { license: string } }; + export async function saveRegistrationData({ workspaceId, client_name, @@ -13,15 +25,7 @@ export async function saveRegistrationData({ client_secret_expires_at, publicKey, registration_client_uri, -}: { - workspaceId: string; - client_name: string; - client_id: string; - client_secret: string; - client_secret_expires_at: number; - publicKey: string; - registration_client_uri: string; -}) { +}: SaveRegistrationDataDTO) { await saveRegistrationDataBase({ workspaceId, client_name, @@ -43,15 +47,7 @@ async function saveRegistrationDataBase({ client_secret_expires_at, publicKey, registration_client_uri, -}: { - workspaceId: string; - client_name: string; - client_id: string; - client_secret: string; - client_secret_expires_at: number; - publicKey: string; - registration_client_uri: string; -}) { +}: SaveRegistrationDataDTO) { const settingsData = [ { _id: 'Register_Server', value: true }, { _id: 'Cloud_Workspace_Id', value: workspaceId }, @@ -63,7 +59,13 @@ async function saveRegistrationDataBase({ { _id: 'Cloud_Workspace_Registration_Client_Uri', value: registration_client_uri }, ]; - const promises = settingsData.map(({ _id, value }) => Settings.updateValueById(_id, value)); + await WorkspaceCredentials.updateCredentialByScope({ + scope: '', + accessToken: '', + expirationDate: new Date(0), + }); + + const promises = [...settingsData.map(({ _id, value }) => Settings.updateValueById(_id, value))]; (await Promise.all(promises)).forEach((value, index) => { if (value?.modifiedCount) { @@ -104,18 +106,7 @@ export async function saveRegistrationDataManual({ publicKey, registration_client_uri, licenseData, -}: { - workspaceId: string; - client_name: string; - client_id: string; - client_secret: string; - client_secret_expires_at: number; - publicKey: string; - registration_client_uri: string; - licenseData: { - license: string; - }; -}) { +}: ManualSaveRegistrationDataDTO) { await saveRegistrationDataBase({ workspaceId, client_name, diff --git a/apps/meteor/ee/server/models/WorkspaceCredentials.ts b/apps/meteor/ee/server/models/WorkspaceCredentials.ts new file mode 100644 index 000000000000..26b1f015f067 --- /dev/null +++ b/apps/meteor/ee/server/models/WorkspaceCredentials.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../../../server/database/utils'; +import { WorkspaceCredentialsRaw } from './raw/WorkspaceCredentials'; + +registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); diff --git a/apps/meteor/ee/server/models/raw/WorkspaceCredentials.ts b/apps/meteor/ee/server/models/raw/WorkspaceCredentials.ts new file mode 100644 index 000000000000..f4141967814d --- /dev/null +++ b/apps/meteor/ee/server/models/raw/WorkspaceCredentials.ts @@ -0,0 +1,68 @@ +import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import type { IWorkspaceCredentialsModel } from '@rocket.chat/model-typings'; +import type { Db, DeleteResult, Filter, IndexDescription, UpdateResult } from 'mongodb'; + +import { BaseRaw } from '../../../../server/models/raw/BaseRaw'; + +export class WorkspaceCredentialsRaw extends BaseRaw implements IWorkspaceCredentialsModel { + constructor(db: Db) { + super(db, 'workspace_credentials'); + } + + protected modelIndexes(): IndexDescription[] { + return [{ key: { scopes: 1, expirationDate: 1, accessToken: 1 }, unique: true }]; + } + + getCredentialByScope(scope = ''): Promise { + const query: Filter = { + scopes: { + $all: [scope], + $size: 1, + }, + }; + + return this.findOne(query); + } + + unsetCredentialByScope(scope = ''): Promise { + const query: Filter = { + scopes: { + $all: [scope], + $size: 1, + }, + }; + + return this.deleteOne(query); + } + + updateCredentialByScope({ + scope, + accessToken, + expirationDate, + }: { + scope: string; + accessToken: string; + expirationDate: Date; + }): Promise { + const record = { + $set: { + scopes: [scope], + accessToken, + expirationDate, + }, + }; + + const query: Filter = { + scopes: { + $all: [scope], + $size: 1, + }, + }; + + return this.updateOne(query, record, { upsert: true }); + } + + removeAllCredentials(): Promise { + return this.col.deleteMany({}); + } +} diff --git a/apps/meteor/ee/server/models/startup.ts b/apps/meteor/ee/server/models/startup.ts index f77bcd1d7619..08605fe2c4d2 100644 --- a/apps/meteor/ee/server/models/startup.ts +++ b/apps/meteor/ee/server/models/startup.ts @@ -7,6 +7,7 @@ import('./LivechatPriority'); import('./OmnichannelServiceLevelAgreements'); import('./AuditLog'); import('./ReadReceipts'); +import('./WorkspaceCredentials'); void License.onLicense('livechat-enterprise', () => { import('./CannedResponse'); diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts index 25b2fab19cbd..91e3125d1280 100644 --- a/apps/meteor/server/settings/setup-wizard.ts +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -1322,28 +1322,6 @@ export const createSetupWSettings = () => secret: true, }); - await this.add('Cloud_Workspace_Access_Token', '', { - type: 'string', - hidden: true, - readonly: true, - enableQuery: { - _id: 'Register_Server', - value: true, - }, - secret: true, - }); - - await this.add('Cloud_Workspace_Access_Token_Expires_At', new Date(0), { - type: 'date', - hidden: true, - readonly: true, - enableQuery: { - _id: 'Register_Server', - value: true, - }, - secret: true, - }); - await this.add('Cloud_Workspace_Registration_State', '', { type: 'string', hidden: true, diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index bf1cd59dbd0d..aa1e65bca966 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -48,5 +48,6 @@ import './v312'; import './v313'; import './v314'; import './v315'; +import './v316'; export * from './xrun'; diff --git a/apps/meteor/server/startup/migrations/v316.ts b/apps/meteor/server/startup/migrations/v316.ts new file mode 100644 index 000000000000..c8641b896e77 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v316.ts @@ -0,0 +1,31 @@ +import { Settings, WorkspaceCredentials } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 316, + name: 'Remove Cloud_Workspace_Access_Token and Cloud_Workspace_Access_Token_Expires_At from the settings collection and add to the WorkspaceCredentials collection', + async up() { + const workspaceCredentials = await WorkspaceCredentials.getCredentialByScope(); + if (workspaceCredentials) { + return; + } + + const accessToken = ((await Settings.getValueById('Cloud_Workspace_Access_Token')) as string) || ''; + const expirationDate = ((await Settings.getValueById('Cloud_Workspace_Access_Token_Expires_At')) as Date) || new Date(0); + + if (accessToken) { + await Settings.removeById('Cloud_Workspace_Access_Token'); + } + + if (expirationDate) { + await Settings.removeById('Cloud_Workspace_Access_Token_Expires_At'); + } + + await WorkspaceCredentials.updateCredentialByScope({ + scope: '', + accessToken, + expirationDate, + }); + }, +}); diff --git a/packages/core-typings/src/ee/IWorkspaceCredentials.ts b/packages/core-typings/src/ee/IWorkspaceCredentials.ts new file mode 100644 index 000000000000..1acf4570f3cf --- /dev/null +++ b/packages/core-typings/src/ee/IWorkspaceCredentials.ts @@ -0,0 +1,8 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; + +export interface IWorkspaceCredentials extends IRocketChatRecord { + _id: string; + scopes: string[]; + expirationDate: Date; + accessToken: string; +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 5d2e2935a466..56db4cd73b1f 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -42,6 +42,7 @@ export * from './IUserStatus'; export * from './IUser'; export * from './ee/IAuditLog'; +export * from './ee/IWorkspaceCredentials'; export * from './import'; export * from './IIncomingMessage'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 83def2bd19b2..77fe8f012ec9 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -81,3 +81,4 @@ export * from './models/ICronHistoryModel'; export * from './models/IMigrationsModel'; export * from './models/IModerationReportsModel'; export * from './updater'; +export * from './models/IWorkspaceCredentialsModel'; diff --git a/packages/model-typings/src/models/IWorkspaceCredentialsModel.ts b/packages/model-typings/src/models/IWorkspaceCredentialsModel.ts new file mode 100644 index 000000000000..58b9a8a5049d --- /dev/null +++ b/packages/model-typings/src/models/IWorkspaceCredentialsModel.ts @@ -0,0 +1,11 @@ +import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import type { DeleteResult, UpdateResult } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface IWorkspaceCredentialsModel extends IBaseModel { + getCredentialByScope(scope?: string): Promise; + unsetCredentialByScope(scope?: string): Promise; + updateCredentialByScope(credentials: { scope: string; accessToken: string; expirationDate: Date }): Promise; + removeAllCredentials(): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index eb357ed293ef..67bb4dfbcd47 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -79,6 +79,7 @@ import type { ICronHistoryModel, IMigrationsModel, IModerationReportsModel, + IWorkspaceCredentialsModel, } from '@rocket.chat/model-typings'; import { proxify } from './proxify'; @@ -173,3 +174,4 @@ export const AuditLog = proxify('IAuditLogModel'); export const CronHistory = proxify('ICronHistoryModel'); export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); +export const WorkspaceCredentials = proxify('IWorkspaceCredentialsModel');