diff --git a/ee/packages/license/__tests__/MockedLicenseBuilder.ts b/ee/packages/license/__tests__/MockedLicenseBuilder.ts index 316261744da5..4f2b49596be3 100644 --- a/ee/packages/license/__tests__/MockedLicenseBuilder.ts +++ b/ee/packages/license/__tests__/MockedLicenseBuilder.ts @@ -197,7 +197,6 @@ export class MockedLicenseBuilder { export const getReadyLicenseManager = async () => { const license = new LicenseImp(); await license.setWorkspaceUrl('http://localhost:3000'); - await license.setWorkspaceUrl('http://localhost:3000'); license.setLicenseLimitCounter('activeUsers', () => 0); license.setLicenseLimitCounter('guestUsers', () => 0); diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 4c7c5a8255d1..6147d12623bc 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -63,4 +63,57 @@ describe('Event License behaviors', () => { await expect(license.hasValidLicense()).toBe(true); await expect(fn).toBeCalledTimes(1); }); + + describe('behavior:prevent_action event', () => { + it('should emit `behavior:prevent_action` event when the limit is reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `limitReached:activeUsers` event when the limit is reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onLimitReached('activeUsers', fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith(undefined); + }); + }); }); diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index f6a1e7a2b7d5..6810f53e40dd 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -11,6 +11,7 @@ "@swc/jest": "^0.2.26", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", + "@types/bcrypt": "^5.0.0", "@types/jest": "~29.5.3", "@types/ws": "^8.5.5", "babel-plugin-transform-inline-environment-variables": "^0.4.4", @@ -42,6 +43,7 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/jwt": "workspace:^", - "@rocket.chat/logger": "workspace:^" + "@rocket.chat/logger": "workspace:^", + "bcrypt": "^5.0.1" } } diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index b6d52bbfa8c5..8b5af5f3c481 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,8 +1,17 @@ +import type { LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; -export type BehaviorWithContext = { - behavior: LicenseBehavior; - modules?: LicenseModule[]; -}; +export type BehaviorWithContext = + | { + behavior: LicenseBehavior; + modules?: LicenseModule[]; + reason: 'limit'; + limit?: LicenseLimitKind; + } + | { + behavior: LicenseBehavior; + modules?: LicenseModule[]; + reason: 'period' | 'url'; + }; diff --git a/ee/packages/license/src/definition/LicenseValidationOptions.ts b/ee/packages/license/src/definition/LicenseValidationOptions.ts new file mode 100644 index 000000000000..6aa1e4213c62 --- /dev/null +++ b/ee/packages/license/src/definition/LicenseValidationOptions.ts @@ -0,0 +1,11 @@ +import type { LicenseLimitKind } from './ILicenseV3'; +import type { LicenseBehavior } from './LicenseBehavior'; +import type { LimitContext } from './LimitContext'; + +export type LicenseValidationOptions = { + behaviors?: LicenseBehavior[]; + limits?: LicenseLimitKind[]; + suppressLog?: boolean; + isNewLicense?: boolean; + context?: Partial<{ [K in LicenseLimitKind]: Partial> }>; +}; diff --git a/ee/packages/license/src/definition/LimitContext.ts b/ee/packages/license/src/definition/LimitContext.ts index a2c44744bd75..9dfc6d36be7f 100644 --- a/ee/packages/license/src/definition/LimitContext.ts +++ b/ee/packages/license/src/definition/LimitContext.ts @@ -2,4 +2,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { LicenseLimitKind } from './ILicenseV3'; -export type LimitContext = T extends 'roomsPerGuest' ? { userId: IUser['_id'] } : Record; +export type LimitContext = { extraCount?: number } & (T extends 'roomsPerGuest' + ? { userId: IUser['_id'] } + : Record); diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts new file mode 100644 index 000000000000..53f3afe846db --- /dev/null +++ b/ee/packages/license/src/definition/events.ts @@ -0,0 +1,15 @@ +import type { LicenseLimitKind } from './ILicenseV3'; +import type { BehaviorWithContext, LicenseBehavior } from './LicenseBehavior'; +import type { LicenseModule } from './LicenseModule'; + +type ModuleValidation = Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined>; +type BehaviorTriggered = Record<`behavior:${LicenseBehavior}`, { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }>; +type LimitReached = Record<`limitReached:${LicenseLimitKind}`, undefined>; + +export type LicenseEvents = ModuleValidation & + BehaviorTriggered & + LimitReached & { + validate: undefined; + invalidate: undefined; + module: { module: LicenseModule; valid: boolean }; + }; diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9d4025e4bce3..9258fb29444c 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -1,4 +1,4 @@ -import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; @@ -21,10 +21,44 @@ export function moduleRemoved(this: LicenseManager, module: LicenseModule) { } } -export function limitReached(this: LicenseManager, limitKind: LicenseLimitKind) { +export function behaviorTriggered(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; try { - this.emit(`limitReached:${limitKind}`); + this.emit(`behavior:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } + + if (behavior !== 'prevent_action') { + return; + } + + if (reason !== 'limit' || !(`limit` in rest) || !rest.limit) { + return; + } + + try { + this.emit(`limitReached:${rest.limit}`); } catch (error) { logger.error({ msg: 'Error running limit reached event', error }); } } + +export function licenseValidated(this: LicenseManager) { + try { + this.emit('validate'); + } catch (error) { + logger.error({ msg: 'Error running license validated event', error }); + } +} + +export function licenseInvalidated(this: LicenseManager) { + try { + this.emit('invalidate'); + } catch (error) { + logger.error({ msg: 'Error running license invalidated event', error }); + } +} diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index d6e9fb016f2c..ecabecb28c0f 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -1,4 +1,5 @@ import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { hasModule } from '../modules'; @@ -58,18 +59,26 @@ export function onToggledFeature( }; } -export function onModule(this: LicenseManager, cb: (...args: any[]) => void) { +export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; valid: boolean }) => void) { this.on('module', cb); } -export function onValidateLicense(this: LicenseManager, cb: (...args: any[]) => void) { +export function onValidateLicense(this: LicenseManager, cb: () => void) { this.on('validate', cb); } -export function onInvalidateLicense(this: LicenseManager, cb: (...args: any[]) => void) { +export function onInvalidateLicense(this: LicenseManager, cb: () => void) { this.on('invalidate', cb); } -export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: (...args: any[]) => void) { +export function onBehaviorTriggered( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behavior:${behavior}`, cb); +} + +export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) { this.on(`limitReached:${limitKind}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index c5dbd9f9496f..fe5f3a84a12f 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -4,6 +4,7 @@ import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; import { + onBehaviorTriggered, onInvalidFeature, onInvalidateLicense, onLimitReached, @@ -45,6 +46,8 @@ interface License { onValidateLicense: typeof onValidateLicense; onInvalidateLicense: typeof onInvalidateLicense; onLimitReached: typeof onLimitReached; + onBehaviorTriggered: typeof onBehaviorTriggered; + revalidateLicense: () => Promise; getInfo: (loadCurrentValues: boolean) => Promise<{ license: ILicenseV3 | undefined; @@ -78,7 +81,7 @@ export class LicenseImp extends LicenseManager implements License { getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit; - public async isLimitReached(action: T, context?: Partial>) { + public async isLimitReached(action: T, context?: Partial>): Promise { return this.shouldPreventAction(action, context, 0); } @@ -96,6 +99,8 @@ export class LicenseImp extends LicenseManager implements License { onLimitReached = onLimitReached; + onBehaviorTriggered = onBehaviorTriggered; + // Deprecated: onLicense = onLicense; diff --git a/ee/packages/license/src/isItemAllowed.ts b/ee/packages/license/src/isItemAllowed.ts new file mode 100644 index 000000000000..16787cdf9c4d --- /dev/null +++ b/ee/packages/license/src/isItemAllowed.ts @@ -0,0 +1,12 @@ +import type { LicenseLimitKind } from './definition/ILicenseV3'; +import type { LicenseBehavior } from './definition/LicenseBehavior'; +import type { LicenseValidationOptions } from './definition/LicenseValidationOptions'; + +const isItemAllowed = (item: T, allowList?: T[]): boolean => { + return !allowList || allowList.includes(item); +}; + +export const isLimitAllowed = (item: LicenseLimitKind, options: LicenseValidationOptions): boolean => isItemAllowed(item, options.limits); + +export const isBehaviorAllowed = (item: LicenseBehavior, options: LicenseValidationOptions): boolean => + isItemAllowed(item, options.behaviors) && (options.isNewLicense || item !== 'prevent_installation'); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index a420eb2b0d57..4121c70267da 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -4,10 +4,13 @@ import type { ILicenseV2 } from './definition/ILicenseV2'; import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; import type { BehaviorWithContext } from './definition/LicenseBehavior'; import type { LicenseModule } from './definition/LicenseModule'; +import type { LicenseValidationOptions } from './definition/LicenseValidationOptions'; import type { LimitContext } from './definition/LimitContext'; +import type { LicenseEvents } from './definition/events'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; +import { behaviorTriggered, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; @@ -22,13 +25,9 @@ import { isReadyForValidation } from './validation/isReadyForValidation'; import { runValidation } from './validation/runValidation'; import { validateFormat } from './validation/validateFormat'; -export class LicenseManager extends Emitter< - Record<`limitReached:${LicenseLimitKind}` | `${'invalid' | 'valid'}:${LicenseModule}`, undefined> & { - validate: undefined; - invalidate: undefined; - module: { module: LicenseModule; valid: boolean }; - } -> { +const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; + +export class LicenseManager extends Emitter { dataCounters = new Map) => Promise>(); pendingLicense = ''; @@ -43,7 +42,7 @@ export class LicenseManager extends Emitter< private _valid: boolean | undefined; - private _inFairPolicy: boolean | undefined; + private _inFairPolicy = false; private _lockedLicense: string | undefined; @@ -60,7 +59,7 @@ export class LicenseManager extends Emitter< } public get inFairPolicy(): boolean { - return Boolean(this._inFairPolicy); + return this._inFairPolicy; } public async setWorkspaceUrl(url: string) { @@ -75,15 +74,34 @@ export class LicenseManager extends Emitter< return this.workspaceUrl; } + public async revalidateLicense(options: Omit = {}): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + await this.validateLicense({ ...options, isNewLicense: false }); + } finally { + if (!this.hasValidLicense()) { + this.invalidateLicense(); + } + } + } + private clearLicenseData(): void { this._license = undefined; this._unmodifiedLicense = undefined; - this._inFairPolicy = undefined; + this._inFairPolicy = false; this._valid = false; this._lockedLicense = undefined; clearPendingLicense.call(this); } + private invalidateLicense(): void { + licenseInvalidated.call(this); + invalidateAll.call(this); + } + private async setLicenseV3(newLicense: ILicenseV3, encryptedLicense: string, originalLicense?: ILicenseV2 | ILicenseV3): Promise { const hadValidLicense = this.hasValidLicense(); this.clearLicenseData(); @@ -92,13 +110,16 @@ export class LicenseManager extends Emitter< this._unmodifiedLicense = originalLicense || newLicense; this._license = newLicense; - await this.validateLicense(); - + await this.validateLicense({ isNewLicense: encryptedLicense !== this._lockedLicense }); this._lockedLicense = encryptedLicense; + + if (this.valid) { + licenseValidated.call(this); + showLicense.call(this, this._license, this._valid); + } } finally { if (hadValidLicense && !this.hasValidLicense()) { - this.emit('invalidate'); - invalidateAll.call(this); + this.invalidateLicense(); } } } @@ -111,7 +132,7 @@ export class LicenseManager extends Emitter< return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense); } - private async validateLicense(): Promise { + private async validateLicense(options: LicenseValidationOptions = {}): Promise { if (!this._license) { throw new InvalidLicenseError(); } @@ -120,15 +141,11 @@ export class LicenseManager extends Emitter< throw new NotReadyForValidation(); } - // #TODO: Only include 'prevent_installation' here if this is actually the initial installation of the license - const validationResult = await runValidation.call(this, this._license, [ - 'invalidate_license', - 'prevent_installation', - 'start_fair_policy', - 'disable_modules', - ]); - - this.processValidationResult(validationResult); + const validationResult = await runValidation.call(this, this._license, options); + this.processValidationResult(validationResult, options); + if (!options.isNewLicense) { + this.triggerBehaviorEvents(validationResult); + } } public async setLicense(encryptedLicense: string): Promise { @@ -175,13 +192,18 @@ export class LicenseManager extends Emitter< } } - private processValidationResult(result: BehaviorWithContext[]): void { + private processValidationResult(result: BehaviorWithContext[], options: LicenseValidationOptions): void { if (!this._license || isBehaviorsInResult(result, ['invalidate_license', 'prevent_installation'])) { + this._valid = false; return; } + const shouldLogModules = !this._valid || options.isNewLicense; + this._valid = true; - this._inFairPolicy = isBehaviorsInResult(result, ['start_fair_policy']); + if (isBehaviorsInResult(result, ['start_fair_policy'])) { + this._inFairPolicy = true; + } if (this._license.information.tags) { replaceTags(this._license.information.tags); @@ -190,14 +212,20 @@ export class LicenseManager extends Emitter< const disabledModules = getModulesToDisable(result); const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module)); - replaceModules.call( + const modulesChanged = replaceModules.call( this, modulesToEnable.map(({ module }) => module), ); - logger.log({ msg: 'License validated', modules: modulesToEnable }); - this.emit('validate'); - showLicense.call(this, this._license, this._valid); + if (shouldLogModules || modulesChanged) { + logger.log({ msg: 'License validated', modules: modulesToEnable }); + } + } + + private triggerBehaviorEvents(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggered.call(this, { ...options }); + } } public hasValidLicense(): boolean { @@ -212,20 +240,31 @@ export class LicenseManager extends Emitter< public async shouldPreventAction( action: T, - context?: Partial>, + context: Partial> = {}, newCount = 1, + { suppressLog }: Pick = {}, ): Promise { const license = this.getLicense(); if (!license) { return false; } - const currentValue = (await getCurrentValueForLicenseLimit.call(this, action, context)) + newCount; - return Boolean( - license.limits[action] - ?.filter(({ behavior, max }) => behavior === 'prevent_action' && max >= 0) - .some(({ max }) => max < currentValue), - ); + const options: LicenseValidationOptions = { + behaviors: ['prevent_action'], + isNewLicense: false, + suppressLog: !!suppressLog, + context: { + [action]: { + extraCount: newCount, + ...context, + }, + }, + }; + + const validationResult = await runValidation.call(this, license, options); + this.triggerBehaviorEvents(validationResult); + + return isBehaviorsInResult(validationResult, ['prevent_action']); } public async getInfo(loadCurrentValues = false): Promise<{ @@ -241,7 +280,7 @@ export class LicenseManager extends Emitter< const limits = ( (license && (await Promise.all( - (['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts'] as LicenseLimitKind[]) + globalLimitKinds .map((limitKey) => ({ limitKey, max: Math.max(-1, Math.min(...Array.from(license.limits[limitKey as LicenseLimitKind] || [])?.map(({ max }) => max))), diff --git a/ee/packages/license/src/modules.ts b/ee/packages/license/src/modules.ts index 7570ec525fc7..6931fb7a6a5d 100644 --- a/ee/packages/license/src/modules.ts +++ b/ee/packages/license/src/modules.ts @@ -29,7 +29,8 @@ export function hasModule(this: LicenseManager, module: LicenseModule) { return this.modules.has(module); } -export function replaceModules(this: LicenseManager, newModules: LicenseModule[]) { +export function replaceModules(this: LicenseManager, newModules: LicenseModule[]): boolean { + let anyChange = false; for (const moduleName of newModules) { if (this.modules.has(moduleName)) { continue; @@ -37,6 +38,7 @@ export function replaceModules(this: LicenseManager, newModules: LicenseModule[] this.modules.add(moduleName); moduleValidated.call(this, moduleName); + anyChange = true; } for (const moduleName of this.modules) { @@ -46,5 +48,8 @@ export function replaceModules(this: LicenseManager, newModules: LicenseModule[] moduleRemoved.call(this, moduleName); this.modules.delete(moduleName); + anyChange = true; } + + return anyChange; } diff --git a/ee/packages/license/src/v2/convertToV3.ts b/ee/packages/license/src/v2/convertToV3.ts index 7586f54c8c54..10681cf04b47 100644 --- a/ee/packages/license/src/v2/convertToV3.ts +++ b/ee/packages/license/src/v2/convertToV3.ts @@ -36,7 +36,7 @@ export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => { serverUrls: [ { value: v2.url, - type: 'url', + type: 'regex', }, ], validPeriods: [ diff --git a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts index 88cedc6c7bc9..60443cc408bb 100644 --- a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts +++ b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts @@ -1,12 +1,9 @@ -import type { IUser } from '@rocket.chat/core-typings'; - import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { LimitContext } from '../definition/LimitContext'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { applyPendingLicense, hasPendingLicense } from '../pendingLicense'; -type LimitContext = T extends 'roomsPerGuest' ? { userId: IUser['_id'] } : Record; - export function setLicenseLimitCounter( this: LicenseManager, limitKey: T, @@ -30,7 +27,11 @@ export async function getCurrentValueForLicenseLimit throw new Error('Unable to validate license limit due to missing data counter.'); } - return counterFn(context as LimitContext | undefined); + const extraCount = context?.extraCount || 0; + + const count = await counterFn(context as LimitContext | undefined); + + return count + extraCount; } export function hasAllDataCounters(this: LicenseManager) { diff --git a/ee/packages/license/src/validation/getResultingBehavior.ts b/ee/packages/license/src/validation/getResultingBehavior.ts index 47e2d91b8b89..22ca02bfd220 100644 --- a/ee/packages/license/src/validation/getResultingBehavior.ts +++ b/ee/packages/license/src/validation/getResultingBehavior.ts @@ -1,8 +1,12 @@ +import type { LicenseLimitKind } from '../definition/ILicenseV3'; import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { LicenseLimit } from '../definition/LicenseLimit'; import type { LicensePeriod } from '../definition/LicensePeriod'; -export const getResultingBehavior = (data: LicenseLimit | LicensePeriod | Partial): BehaviorWithContext => { +export const getResultingBehavior = ( + data: LicenseLimit | LicensePeriod | Partial>, + { reason, limit }: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }, +): BehaviorWithContext => { const behavior = 'invalidBehavior' in data ? data.invalidBehavior : data.behavior; switch (behavior) { @@ -10,11 +14,15 @@ export const getResultingBehavior = (data: LicenseLimit | LicensePeriod | Partia return { behavior, modules: ('modules' in data && data.modules) || [], + reason, + limit, }; default: return { behavior, + reason, + limit, } as BehaviorWithContext; } }; diff --git a/ee/packages/license/src/validation/runValidation.spec.ts b/ee/packages/license/src/validation/runValidation.spec.ts index 98797c86cd27..523090acd63a 100644 --- a/ee/packages/license/src/validation/runValidation.spec.ts +++ b/ee/packages/license/src/validation/runValidation.spec.ts @@ -22,16 +22,16 @@ describe('Validation behaviors', () => { }); await expect( - runValidation.call(licenseManager, await license.build(), [ - 'invalidate_license', - 'prevent_installation', - 'start_fair_policy', - 'disable_modules', - ]), + runValidation.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), ).resolves.toStrictEqual([ { behavior: 'disable_modules', + limit: undefined, modules: ['livechat-enterprise'], + reason: 'period', }, ]); }); diff --git a/ee/packages/license/src/validation/runValidation.ts b/ee/packages/license/src/validation/runValidation.ts index 9cb623b8eae0..922b4c49162e 100644 --- a/ee/packages/license/src/validation/runValidation.ts +++ b/ee/packages/license/src/validation/runValidation.ts @@ -1,5 +1,6 @@ import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { LicenseBehavior, BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; import type { LicenseManager } from '../license'; import { validateLicenseLimits } from './validateLicenseLimits'; import { validateLicensePeriods } from './validateLicensePeriods'; @@ -8,15 +9,11 @@ import { validateLicenseUrl } from './validateLicenseUrl'; export async function runValidation( this: LicenseManager, license: ILicenseV3, - behaviorsToValidate: LicenseBehavior[] = [], + options: LicenseValidationOptions, ): Promise { - const shouldValidateBehavior = (behavior: LicenseBehavior) => !behaviorsToValidate.length || behaviorsToValidate.includes(behavior); - return [ - ...new Set([ - ...validateLicenseUrl.call(this, license, shouldValidateBehavior), - ...validateLicensePeriods(license, shouldValidateBehavior), - ...(await validateLicenseLimits.call(this, license, shouldValidateBehavior)), - ]), + ...validateLicenseUrl.call(this, license, options), + ...validateLicensePeriods(license, options), + ...(await validateLicenseLimits.call(this, license, options)), ]; } diff --git a/ee/packages/license/src/validation/validateLicenseLimits.ts b/ee/packages/license/src/validation/validateLicenseLimits.ts index 168effe6a250..cd2674350946 100644 --- a/ee/packages/license/src/validation/validateLicenseLimits.ts +++ b/ee/packages/license/src/validation/validateLicenseLimits.ts @@ -1,5 +1,7 @@ -import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { ILicenseV3, LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isLimitAllowed, isBehaviorAllowed } from '../isItemAllowed'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { getCurrentValueForLicenseLimit } from './getCurrentValueForLicenseLimit'; @@ -8,30 +10,34 @@ import { getResultingBehavior } from './getResultingBehavior'; export async function validateLicenseLimits( this: LicenseManager, license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, + options: LicenseValidationOptions, ): Promise { const { limits } = license; - const limitKeys = Object.keys(limits) as (keyof ILicenseV3['limits'])[]; + const limitKeys = (Object.keys(limits) as LicenseLimitKind[]).filter((limit) => isLimitAllowed(limit, options)); return ( await Promise.all( limitKeys.map(async (limitKey) => { // Filter the limit list before running any query in the database so we don't end up loading some value we won't use. - const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && behaviorFilter(behavior)); + const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && isBehaviorAllowed(behavior, options)); if (!limitList?.length) { return []; } - const currentValue = await getCurrentValueForLicenseLimit.call(this, limitKey); + const currentValue = await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey]); + return limitList .filter(({ max }) => max < currentValue) .map((limit) => { - logger.error({ - msg: 'Limit validation failed', - kind: limitKey, - limit, - }); - return getResultingBehavior(limit); + if (!options.suppressLog) { + logger.error({ + msg: 'Limit validation failed', + kind: limitKey, + limit, + }); + } + + return getResultingBehavior(limit, { reason: 'limit', limit: limitKey }); }); }), ) diff --git a/ee/packages/license/src/validation/validateLicensePeriods.ts b/ee/packages/license/src/validation/validateLicensePeriods.ts index 5b3fae433e38..fb27f72d0a8e 100644 --- a/ee/packages/license/src/validation/validateLicensePeriods.ts +++ b/ee/packages/license/src/validation/validateLicensePeriods.ts @@ -1,6 +1,8 @@ import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { Timestamp } from '../definition/LicensePeriod'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isBehaviorAllowed } from '../isItemAllowed'; import { logger } from '../logger'; import { getResultingBehavior } from './getResultingBehavior'; @@ -18,21 +20,23 @@ export const isPeriodInvalid = (from: Timestamp | undefined, until: Timestamp | return false; }; -export const validateLicensePeriods = ( - license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, -): BehaviorWithContext[] => { +export const validateLicensePeriods = (license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] => { const { validation: { validPeriods }, } = license; return validPeriods - .filter(({ validFrom, validUntil, invalidBehavior }) => behaviorFilter(invalidBehavior) && isPeriodInvalid(validFrom, validUntil)) + .filter( + ({ validFrom, validUntil, invalidBehavior }) => isBehaviorAllowed(invalidBehavior, options) && isPeriodInvalid(validFrom, validUntil), + ) .map((period) => { - logger.error({ - msg: 'Period validation failed', - period, - }); - return getResultingBehavior(period); + if (!options.suppressLog) { + logger.error({ + msg: 'Period validation failed', + period, + }); + } + + return getResultingBehavior(period, { reason: 'period' }); }); }; diff --git a/ee/packages/license/src/validation/validateLicenseUrl.spec.ts b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts new file mode 100644 index 000000000000..9047876f8fbc --- /dev/null +++ b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ + +import crypto from 'crypto'; + +import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder'; +import { validateLicenseUrl } from './validateLicenseUrl'; + +describe('Url Validation', () => { + describe('url method', () => { + it('should return a behavior if the license url is invalid', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:3001', + type: 'url', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + + it('should return an empty array if the license url is valid', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:3000', + type: 'url', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); + + describe('regex method', () => { + it('should return a behavior if the license does not match the regex', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'unstable.rocket.*', + type: 'regex', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + + it('should return an empty array if the license matches the regex', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:300*', + type: 'regex', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); + + describe('hash method', () => { + it('should return a behavior if the license does not match the hash', async () => { + const licenseManager = await getReadyLicenseManager(); + + const hash = crypto.createHash('sha256').update('localhost:3001').digest('hex'); + const license = await new MockedLicenseBuilder().withServerUrls({ + value: hash, + type: 'hash', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + it('should return an empty array if the license matches the hash', async () => { + const licenseManager = await getReadyLicenseManager(); + + const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex'); + const license = await new MockedLicenseBuilder().withServerUrls({ + value: hash, + type: 'hash', + }); + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); +}); diff --git a/ee/packages/license/src/validation/validateLicenseUrl.ts b/ee/packages/license/src/validation/validateLicenseUrl.ts index 55cd076c4378..416b107511cb 100644 --- a/ee/packages/license/src/validation/validateLicenseUrl.ts +++ b/ee/packages/license/src/validation/validateLicenseUrl.ts @@ -1,10 +1,14 @@ +import crypto from 'crypto'; + import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isBehaviorAllowed } from '../isItemAllowed'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { getResultingBehavior } from './getResultingBehavior'; -export const validateUrl = (licenseURL: string, url: string) => { +const validateRegex = (licenseURL: string, url: string) => { licenseURL = licenseURL .replace(/\./g, '\\.') // convert dots to literal .replace(/\*/g, '.*'); // convert * to .* @@ -13,12 +17,17 @@ export const validateUrl = (licenseURL: string, url: string) => { return !!regex.exec(url); }; -export function validateLicenseUrl( - this: LicenseManager, - license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, -): BehaviorWithContext[] { - if (!behaviorFilter('invalidate_license')) { +const validateUrl = (licenseURL: string, url: string) => { + return licenseURL.toLowerCase() === url.toLowerCase(); +}; + +const validateHash = (licenseURL: string, url: string) => { + const value = crypto.createHash('sha256').update(url).digest('hex'); + return licenseURL === value; +}; + +export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] { + if (!isBehaviorAllowed('invalidate_license', options)) { return []; } @@ -30,18 +39,16 @@ export function validateLicenseUrl( if (!workspaceUrl) { logger.error('Unable to validate license URL without knowing the workspace URL.'); - return [getResultingBehavior({ behavior: 'invalidate_license' })]; + return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })]; } return serverUrls .filter((url) => { switch (url.type) { case 'regex': - // #TODO - break; + return !validateRegex(url.value, workspaceUrl); case 'hash': - // #TODO - break; + return !validateHash(url.value, workspaceUrl); case 'url': return !validateUrl(url.value, workspaceUrl); } @@ -49,11 +56,13 @@ export function validateLicenseUrl( return false; }) .map((url) => { - logger.error({ - msg: 'Url validation failed', - url, - workspaceUrl, - }); - return getResultingBehavior({ behavior: 'invalidate_license' }); + if (!options.suppressLog) { + logger.error({ + msg: 'Url validation failed', + url, + workspaceUrl, + }); + } + return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' }); }); } diff --git a/yarn.lock b/yarn.lock index 4093777c8e33..7d75c388dc29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8485,9 +8485,11 @@ __metadata: "@swc/jest": ^0.2.26 "@types/babel__core": ^7 "@types/babel__preset-env": ^7 + "@types/bcrypt": ^5.0.0 "@types/jest": ~29.5.3 "@types/ws": ^8.5.5 babel-plugin-transform-inline-environment-variables: ^0.4.4 + bcrypt: ^5.0.1 eslint: ~8.45.0 jest: ~29.6.1 jest-environment-jsdom: ~29.6.1