diff --git a/.changeset/license-validate-preview.md b/.changeset/license-validate-preview.md new file mode 100644 index 0000000000000..167c13a993557 --- /dev/null +++ b/.changeset/license-validate-preview.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/license': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a new `licenses.validate` REST endpoint that validates a Rocket.Chat license (V2 or V3 JWT) against the current workspace's validation structure without applying it, returning the validation details (validity, granted modules and any validation errors) so a license can be previewed before it is applied from the UI. diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index f40c5d1addc33..7ba3eb01180d9 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -1,6 +1,6 @@ import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; -import { isLicensesInfoProps } from '@rocket.chat/rest-typings'; +import { isLicensesInfoProps, isLicensesValidateProps } from '@rocket.chat/rest-typings'; import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; @@ -71,6 +71,20 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'licenses.validate', + { authRequired: true, permissionsRequired: ['edit-privileged-setting'], validateParams: isLicensesValidateProps }, + { + async post() { + const { license } = this.bodyParams; + + const validation = await License.validateLicenseForPreview(license); + + return API.v1.success({ validation }); + }, + }, +); + API.v1.addRoute( 'licenses.maxActiveUsers', { authRequired: true }, diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 0e92af730b1cf..5410a2884d806 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -8,6 +8,7 @@ import type { LicenseBehavior, LicenseInfo, LicenseValidationOptions, + LicenseValidationResult, LimitContext, LicenseModule, } from '@rocket.chat/core-typings'; @@ -52,6 +53,15 @@ import { validateLicenseLimits } from './validation/validateLicenseLimits'; const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; +// The behaviors that decide whether (and how) a license is installed. Shared between the actual +// validation performed on apply and the dry-run preview so both stay in sync if a behavior is added. +const licenseValidationBehaviors: LicenseBehavior[] = [ + 'invalidate_license', + 'start_fair_policy', + 'prevent_installation', + 'disable_modules', +]; + export abstract class LicenseManager extends Emitter { abstract validateFormat: typeof validateFormat; @@ -267,7 +277,7 @@ export abstract class LicenseManager extends Emitter { } const validationResult = await runValidation.call(this, this._license, { - behaviors: ['invalidate_license', 'start_fair_policy', 'prevent_installation', 'disable_modules'], + behaviors: licenseValidationBehaviors, ...options, }); @@ -312,6 +322,59 @@ export abstract class LicenseManager extends Emitter { } } + /** + * Validates a license against the current workspace state without applying it. + * + * This decrypts and verifies the license string, then runs the same validation pipeline + * used when a license is set (URL, periods and limits), but does not mutate any internal + * state nor emit any events. It is meant to power a preview of what would happen if the + * license were applied — e.g. before committing to it from the admin UI. + */ + public async validateLicenseForPreview(encryptedLicense: string): Promise { + const workspaceUrl = this.getWorkspaceUrl(); + + let license: ILicenseV3; + try { + await validateFormat(encryptedLicense); + + const decrypted = JSON.parse(await decrypt(encryptedLicense)); + license = encryptedLicense.startsWith('RCV3_') ? decrypted : convertToV3(decrypted); + } catch (err) { + logger.error({ msg: 'Invalid license provided for validation preview', err }); + return { + isFormatValid: false, + isValid: false, + workspaceUrl, + grantedModules: [], + validationErrors: [], + }; + } + + // Run the full validation pipeline against the current workspace state. Unlike `validateLicense`, + // this does not store the license, change validity, replace modules/tags nor emit events. + // In addition to the behaviors that determine installation, also surface `prevent_action` so the + // preview can report limits that are already exceeded by the current workspace. + const validationResult = await runValidation.call(this, license, { + behaviors: [...licenseValidationBehaviors, 'prevent_action'], + isNewLicense: true, + suppressLog: true, + }); + + const isValid = !isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation']); + + const disabledModules = getModulesToDisable(validationResult); + const grantedModules = license.grantedModules.filter(({ module }) => !disabledModules.includes(module)).map(({ module }) => module); + + return { + isFormatValid: true, + isValid, + license, + workspaceUrl, + grantedModules, + validationErrors: validationResult, + }; + } + public async setLicense(encryptedLicense: string, isNewLicense = true): Promise { if (!(await validateFormat(encryptedLicense))) { throw new InvalidLicenseError(); @@ -408,10 +471,10 @@ export abstract class LicenseManager extends Emitter { const items = await Promise.all( keys.map(async (limit) => { - const cached = this.shouldPreventActionResults.get(limit as LicenseLimitKind); + const cached = this.shouldPreventActionResults.get(limit); if (cached !== undefined) { - return [limit as LicenseLimitKind, cached]; + return [limit, cached]; } const fresh = license @@ -426,9 +489,9 @@ export abstract class LicenseManager extends Emitter { 'prevent_action', ]); - this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh); + this.shouldPreventActionResults.set(limit, fresh); - return [limit as LicenseLimitKind, fresh]; + return [limit, fresh]; }), ); diff --git a/ee/packages/license/src/validateLicenseForPreview.spec.ts b/ee/packages/license/src/validateLicenseForPreview.spec.ts new file mode 100644 index 0000000000000..cbbd5bc829625 --- /dev/null +++ b/ee/packages/license/src/validateLicenseForPreview.spec.ts @@ -0,0 +1,95 @@ +import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder'; + +describe('validateLicenseForPreview', () => { + it('should report an invalid format for a non-license string', async () => { + const licenseManager = await getReadyLicenseManager(); + + const result = await licenseManager.validateLicenseForPreview('not-a-license'); + + expect(result.isFormatValid).toBe(false); + expect(result.isValid).toBe(false); + expect(result.license).toBeUndefined(); + expect(result.grantedModules).toEqual([]); + }); + + it('should preview a valid license without applying it', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']).sign(); + + const result = await licenseManager.validateLicenseForPreview(license); + + expect(result.isFormatValid).toBe(true); + expect(result.isValid).toBe(true); + expect(result.license?.version).toBe('3.0'); + expect(result.grantedModules).toEqual(expect.arrayContaining(['livechat-enterprise', 'engagement-dashboard'])); + expect(result.validationErrors).toEqual([]); + + // previewing must not apply the license + expect(licenseManager.hasValidLicense()).toBe(false); + expect(licenseManager.getModules()).toEqual([]); + }); + + it('should not apply a license even when it is already active and being previewed again', async () => { + const licenseManager = await getReadyLicenseManager(); + + const builder = new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise']); + const license = await builder.sign(); + + await licenseManager.setLicense(license); + expect(licenseManager.hasValidLicense()).toBe(true); + + const result = await licenseManager.validateLicenseForPreview(license); + + expect(result.isFormatValid).toBe(true); + expect(result.isValid).toBe(true); + }); + + it('should report an invalid license when the workspace URL does not match', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ value: 'another-workspace.com', type: 'url' }).sign(); + + const result = await licenseManager.validateLicenseForPreview(license); + + expect(result.isFormatValid).toBe(true); + expect(result.isValid).toBe(false); + expect(result.validationErrors).toEqual( + expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'url' })]), + ); + }); + + it('should report an invalid license when an invalidating period has expired', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().resetValidPeriods().withExpiredDate().sign(); + + const result = await licenseManager.validateLicenseForPreview(license); + + expect(result.isFormatValid).toBe(true); + expect(result.isValid).toBe(false); + expect(result.validationErrors).toEqual( + expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'period' })]), + ); + }); + + it('should still be valid but exclude modules disabled by an expired period', async () => { + const licenseManager = await getReadyLicenseManager(); + + // MockedLicenseBuilder seeds a `disable_modules` period for `livechat-enterprise`; expire it. + const builder = new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']); + builder.validation.validPeriods = [ + { + invalidBehavior: 'disable_modules', + modules: ['livechat-enterprise'], + validUntil: new Date(new Date().setMinutes(new Date().getMinutes() - 1)).toISOString(), + }, + ]; + + const result = await licenseManager.validateLicenseForPreview(await builder.sign()); + + expect(result.isValid).toBe(true); + expect(result.grantedModules).toContain('engagement-dashboard'); + expect(result.grantedModules).not.toContain('livechat-enterprise'); + }); +}); diff --git a/packages/core-typings/src/license/LicenseValidationResult.ts b/packages/core-typings/src/license/LicenseValidationResult.ts new file mode 100644 index 0000000000000..613dba0db03de --- /dev/null +++ b/packages/core-typings/src/license/LicenseValidationResult.ts @@ -0,0 +1,33 @@ +import type { ILicenseV3 } from './ILicenseV3'; +import type { BehaviorWithContext } from './LicenseBehavior'; +import type { LicenseModule } from './LicenseModule'; + +/** + * Result of validating a license without applying it. + * + * Used to preview what would happen if a given license were applied to the current + * workspace, so the outcome can be shown to an admin before committing to it. + */ +export type LicenseValidationResult = { + /** + * Whether the provided string is a well-formed, signature-valid license that could be decoded. + * When `false`, no further details are available. + */ + isFormatValid: boolean; + /** + * Whether the license would be accepted by this workspace, i.e. applying it would not + * invalidate it nor prevent its installation. + */ + isValid: boolean; + /** The decoded license. Present only when `isFormatValid` is `true`. */ + license?: ILicenseV3; + /** The workspace URL the license was validated against. */ + workspaceUrl?: string; + /** The modules that would be enabled if the license were applied. */ + grantedModules: LicenseModule[]; + /** + * The validation behaviors triggered while validating the license, describing why it is + * (in)valid — e.g. a workspace URL mismatch, an expired period or an exceeded limit. + */ + validationErrors: BehaviorWithContext[]; +}; diff --git a/packages/core-typings/src/license/index.ts b/packages/core-typings/src/license/index.ts index fb1007faee2a3..6aa251af1eaf3 100644 --- a/packages/core-typings/src/license/index.ts +++ b/packages/core-typings/src/license/index.ts @@ -8,4 +8,5 @@ export type * from './LicenseLimit'; export * from './LicenseModule'; export type * from './LicensePeriod'; export type * from './LicenseValidationOptions'; +export type * from './LicenseValidationResult'; export type * from './LimitContext'; diff --git a/packages/rest-typings/src/v1/licenses.ts b/packages/rest-typings/src/v1/licenses.ts index cca1168a8874d..38d8e6eacbb7c 100644 --- a/packages/rest-typings/src/v1/licenses.ts +++ b/packages/rest-typings/src/v1/licenses.ts @@ -1,4 +1,4 @@ -import type { LicenseInfo, Cloud } from '@rocket.chat/core-typings'; +import type { LicenseInfo, LicenseValidationResult, Cloud } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; @@ -36,6 +36,23 @@ const licensesInfoPropsSchema = { export const isLicensesInfoProps = ajvQuery.compile(licensesInfoPropsSchema); +type licensesValidateProps = { + license: string; +}; + +const licensesValidatePropsSchema = { + type: 'object', + properties: { + license: { + type: 'string', + }, + }, + required: ['license'], + additionalProperties: false, +}; + +export const isLicensesValidateProps = ajv.compile(licensesValidatePropsSchema); + export type LicensesEndpoints = { '/v1/licenses.info': { GET: (params: licensesInfoProps) => { @@ -46,6 +63,9 @@ export type LicensesEndpoints = { '/v1/licenses.add': { POST: (params: licensesAddProps) => void; }; + '/v1/licenses.validate': { + POST: (params: licensesValidateProps) => { validation: LicenseValidationResult }; + }; '/v1/licenses.maxActiveUsers': { GET: () => { maxActiveUsers: number | null; activeUsers: number }; };