From 5b24b162554b99eddd60ac4dc62b6f2e742b5e03 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 3 Oct 2023 20:50:38 -0300 Subject: [PATCH] feat: License v3 validations --- apps/meteor/ee/app/license/server/startup.ts | 17 ++ .../license/src/definition/LicenseBehavior.ts | 8 +- ee/packages/license/src/events/emitter.ts | 26 ++- ee/packages/license/src/events/listeners.ts | 16 +- ee/packages/license/src/index.ts | 17 +- ee/packages/license/src/license.ts | 157 ++++++++++++++---- ee/packages/license/src/modules.ts | 7 +- .../getCurrentValueForLicenseLimit.ts | 9 +- .../license/src/validation/runValidation.ts | 16 +- .../src/validation/validateLicenseLimits.ts | 5 +- 10 files changed, 226 insertions(+), 52 deletions(-) diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index d3523282d1e8..990a381334af 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -1,3 +1,4 @@ +import { cronJobs } from '@rocket.chat/cron'; import { License } from '@rocket.chat/license'; import { Subscriptions, Users } from '@rocket.chat/models'; @@ -26,3 +27,19 @@ License.setLicenseLimitCounter('privateApps', () => getAppCount('private')); License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace')); // #TODO: Get real value License.setLicenseLimitCounter('monthlyActiveContacts', async () => 0); + +const updateCronJob = async () => { + if (License.hasValidLicense() === (await cronJobs.has('licenseLimitChecker'))) { + return; + } + + if (License.hasValidLicense()) { + await cronJobs.add('licenseLimitChecker', '*/30 * * * *', () => License.revalidateLimits()); + } else { + await cronJobs.remove('licenseLimitChecker'); + } +}; + +License.onValidateLicense(async () => updateCronJob()); +License.onInvalidateLicense(async () => updateCronJob()); +void updateCronJob(); diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index b6d52bbfa8c5..776c2ae34074 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,6 +1,12 @@ import type { LicenseModule } from './LicenseModule'; -export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; +export type LicenseBehavior = + | 'invalidate_license' + | 'start_fair_policy' + | 'prevent_action' + | 'prevent_installation' + | 'disable_modules' + | 'custom'; export type BehaviorWithContext = { behavior: LicenseBehavior; diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9d4025e4bce3..deba9aabb715 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -1,4 +1,5 @@ import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { LicenseBehavior } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; @@ -21,10 +22,31 @@ export function moduleRemoved(this: LicenseManager, module: LicenseModule) { } } -export function limitReached(this: LicenseManager, limitKind: LicenseLimitKind) { +export function limitReached( + this: LicenseManager, + limitKind: Exclude, + limitBehavior: Exclude, +) { try { - this.emit(`limitReached:${limitKind}`); + // This will never be emitted for limits that fallback to "not reached" when missing context params (eg: roomsPerGuest) + this.emit(`limitReached:${limitKind}:${limitBehavior}`); } 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..c371e782cb43 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 { LicenseBehavior } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { hasModule } from '../modules'; @@ -58,18 +59,23 @@ 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) { - this.on(`limitReached:${limitKind}`, cb); +export function onLimitReached( + this: LicenseManager, + limitKind: Exclude, + cb: () => void, + limitBehavior: Exclude = 'custom', +) { + this.on(`limitReached:${limitKind}:${limitBehavior}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index c5dbd9f9496f..bc32513a9c90 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -1,4 +1,5 @@ import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; +import type { LicenseBehavior } from './definition/LicenseBehavior'; import type { LicenseModule } from './definition/LicenseModule'; import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; @@ -37,7 +38,11 @@ interface License { overwriteClassOnLicense: typeof overwriteClassOnLicense; setLicenseLimitCounter: typeof setLicenseLimitCounter; getCurrentValueForLicenseLimit: typeof getCurrentValueForLicenseLimit; - isLimitReached: (action: T, context?: Partial>) => Promise; + isLimitReached: ( + action: T, + behaviors: LicenseBehavior[], + context?: Partial>, + ) => Promise; onValidFeature: typeof onValidFeature; onInvalidFeature: typeof onInvalidFeature; onToggledFeature: typeof onToggledFeature; @@ -45,6 +50,8 @@ interface License { onValidateLicense: typeof onValidateLicense; onInvalidateLicense: typeof onInvalidateLicense; onLimitReached: typeof onLimitReached; + revalidateLicense: () => Promise; + revalidateLimits: () => Promise; getInfo: (loadCurrentValues: boolean) => Promise<{ license: ILicenseV3 | undefined; @@ -78,8 +85,12 @@ export class LicenseImp extends LicenseManager implements License { getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit; - public async isLimitReached(action: T, context?: Partial>) { - return this.shouldPreventAction(action, context, 0); + public async isLimitReached( + action: T, + behaviors: LicenseBehavior[], + context?: Partial>, + ): Promise { + return super.isLimitReached(action, behaviors, context); } onValidFeature = onValidFeature; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index a420eb2b0d57..2fba74e87364 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -2,12 +2,13 @@ import { Emitter } from '@rocket.chat/emitter'; import type { ILicenseV2 } from './definition/ILicenseV2'; import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; -import type { BehaviorWithContext } from './definition/LicenseBehavior'; +import type { BehaviorWithContext, LicenseBehavior } from './definition/LicenseBehavior'; import type { LicenseModule } from './definition/LicenseModule'; import type { LimitContext } from './definition/LimitContext'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; +import { licenseInvalidated, licenseValidated, limitReached } from './events/emitter'; import { logger } from './logger'; import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; @@ -22,15 +23,23 @@ import { isReadyForValidation } from './validation/isReadyForValidation'; import { runValidation } from './validation/runValidation'; import { validateFormat } from './validation/validateFormat'; +const invalidLicenseBehaviors: LicenseBehavior[] = ['invalidate_license', 'prevent_installation']; +const generalValidationBehaviors: LicenseBehavior[] = ['start_fair_policy', 'disable_modules']; +const behaviorsWithLimitEvents = ['invalidate_license', 'start_fair_policy', 'disable_modules', 'custom', 'prevent_action'] as const; +const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; + export class LicenseManager extends Emitter< - Record<`limitReached:${LicenseLimitKind}` | `${'invalid' | 'valid'}:${LicenseModule}`, undefined> & { - validate: undefined; - invalidate: undefined; - module: { module: LicenseModule; valid: boolean }; - } + Record<`limitReached:${Exclude}:${Exclude}`, undefined> & + Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined> & { + validate: undefined; + invalidate: undefined; + module: { module: LicenseModule; valid: boolean }; + } > { dataCounters = new Map) => Promise>(); + countersCache = new Map(); + pendingLicense = ''; modules = new Set(); @@ -47,6 +56,8 @@ export class LicenseManager extends Emitter< private _lockedLicense: string | undefined; + private _accessedLimits = new Set>(); + public get license(): ILicenseV3 | undefined { return this._license; } @@ -75,15 +86,61 @@ export class LicenseManager extends Emitter< return this.workspaceUrl; } + public async revalidateLimits(): Promise { + this.countersCache.clear(); + await this.triggerLimitEvents(); + } + + private async triggerLimitEvents(): Promise { + const license = this.getLicense(); + if (!license) { + return; + } + + const limits = [...this._accessedLimits]; + this._accessedLimits.clear(); + + for await (const limit of limits) { + for await (const behavior of behaviorsWithLimitEvents) { + if (this.has(`limitReached:${limit}:${behavior}`) && (await this.isLimitReached(limit, [behavior], undefined, 0, false))) { + limitReached.call(this, limit, behavior); + } + } + } + } + + public async revalidateLicense(): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + this.countersCache.clear(); + await this.validateLicense(false); + } finally { + this.maybeInvalidateLicense(); + } + } + private clearLicenseData(): void { this._license = undefined; this._unmodifiedLicense = undefined; this._inFairPolicy = undefined; this._valid = false; this._lockedLicense = undefined; + this.countersCache.clear(); clearPendingLicense.call(this); } + private maybeInvalidateLicense(): void { + if (this.hasValidLicense()) { + return; + } + + 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 +149,16 @@ export class LicenseManager extends Emitter< this._unmodifiedLicense = originalLicense || newLicense; this._license = newLicense; - await this.validateLicense(); - + await this.validateLicense(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); + if (hadValidLicense) { + this.maybeInvalidateLicense(); } } } @@ -111,7 +171,7 @@ export class LicenseManager extends Emitter< return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense); } - private async validateLicense(): Promise { + private async validateLicenseBehaviors(behaviorsToConsider: LicenseBehavior[]): Promise { if (!this._license) { throw new InvalidLicenseError(); } @@ -120,15 +180,34 @@ 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, [ + // Run the `invalidate_license` behavior first and skip everything else if it's already invalid. + const validationResult = await runValidation.call( + this, + this._license, + behaviorsToConsider.filter((behavior) => invalidLicenseBehaviors.includes(behavior)), + ); + + if (isBehaviorsInResult(validationResult, invalidLicenseBehaviors)) { + this._valid = false; + return; + } + + const generalResult = await runValidation.call( + this, + this._license, + behaviorsToConsider.filter((behavior) => generalValidationBehaviors.includes(behavior)), + ); + + this.processValidationResult(generalResult, behaviorsToConsider.includes('prevent_installation')); + } + + private async validateLicense(isNewLicense: boolean): Promise { + return this.validateLicenseBehaviors([ 'invalidate_license', - 'prevent_installation', 'start_fair_policy', 'disable_modules', + ...(isNewLicense ? ['prevent_installation' as LicenseBehavior] : []), ]); - - this.processValidationResult(validationResult); } public async setLicense(encryptedLicense: string): Promise { @@ -175,11 +254,14 @@ export class LicenseManager extends Emitter< } } - private processValidationResult(result: BehaviorWithContext[]): void { - if (!this._license || isBehaviorsInResult(result, ['invalidate_license', 'prevent_installation'])) { + private processValidationResult(result: BehaviorWithContext[], isNewLicense: boolean): void { + if (!this._license || isBehaviorsInResult(result, invalidLicenseBehaviors)) { + this._valid = false; return; } + const shouldLogModules = !this._valid || isNewLicense; + this._valid = true; this._inFairPolicy = isBehaviorsInResult(result, ['start_fair_policy']); @@ -190,14 +272,14 @@ 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 }); + } } public hasValidLicense(): boolean { @@ -214,18 +296,35 @@ export class LicenseManager extends Emitter< action: T, context?: Partial>, newCount = 1, + ): Promise { + return this.isLimitReached(action, ['prevent_action'], context, newCount); + } + + protected async isLimitReached( + action: T, + behaviorsToConsider?: LicenseBehavior[], + context?: Partial>, + extraCount = 0, + flagAsAccessed = true, ): 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), + if (action !== 'roomsPerGuest' && flagAsAccessed) { + this._accessedLimits.add(action); + } + + const filteredLimits = license.limits[action]?.filter( + ({ behavior, max }) => max >= 0 && (!behaviorsToConsider || behaviorsToConsider.includes(behavior)), ); + if (!filteredLimits?.length) { + return false; + } + + const currentValue = (await getCurrentValueForLicenseLimit.call(this, action, context)) + extraCount; + return Boolean(filteredLimits.some(({ max }) => max < currentValue)); } public async getInfo(loadCurrentValues = false): Promise<{ @@ -241,7 +340,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/validation/getCurrentValueForLicenseLimit.ts b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts index 88cedc6c7bc9..be22c845ada1 100644 --- a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts +++ b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts @@ -30,7 +30,14 @@ export async function getCurrentValueForLicenseLimit throw new Error('Unable to validate license limit due to missing data counter.'); } - return counterFn(context as LimitContext | undefined); + if (this.countersCache.has(limitKey)) { + return this.countersCache.get(limitKey) as number; + } + + const count = await counterFn(context as LimitContext | undefined); + this.countersCache.set(limitKey, count); + + return count; } export function hasAllDataCounters(this: LicenseManager) { diff --git a/ee/packages/license/src/validation/runValidation.ts b/ee/packages/license/src/validation/runValidation.ts index 9cb623b8eae0..50fb8337d483 100644 --- a/ee/packages/license/src/validation/runValidation.ts +++ b/ee/packages/license/src/validation/runValidation.ts @@ -1,4 +1,4 @@ -import type { ILicenseV3 } from '../definition/ILicenseV3'; +import type { ILicenseV3, LicenseLimitKind } from '../definition/ILicenseV3'; import type { LicenseBehavior, BehaviorWithContext } from '../definition/LicenseBehavior'; import type { LicenseManager } from '../license'; import { validateLicenseLimits } from './validateLicenseLimits'; @@ -8,15 +8,15 @@ import { validateLicenseUrl } from './validateLicenseUrl'; export async function runValidation( this: LicenseManager, license: ILicenseV3, - behaviorsToValidate: LicenseBehavior[] = [], + behaviorsToValidate?: LicenseBehavior[], + limitsToValidate?: LicenseLimitKind[], ): Promise { - const shouldValidateBehavior = (behavior: LicenseBehavior) => !behaviorsToValidate.length || behaviorsToValidate.includes(behavior); + const shouldValidateBehavior = (behavior: LicenseBehavior) => !behaviorsToValidate || behaviorsToValidate.includes(behavior); + const shouldValidateLimit = (limit: LicenseLimitKind) => !limitsToValidate || limitsToValidate.includes(limit); return [ - ...new Set([ - ...validateLicenseUrl.call(this, license, shouldValidateBehavior), - ...validateLicensePeriods(license, shouldValidateBehavior), - ...(await validateLicenseLimits.call(this, license, shouldValidateBehavior)), - ]), + ...validateLicenseUrl.call(this, license, shouldValidateBehavior), + ...validateLicensePeriods(license, shouldValidateBehavior), + ...(await validateLicenseLimits.call(this, license, shouldValidateBehavior, shouldValidateLimit)), ]; } diff --git a/ee/packages/license/src/validation/validateLicenseLimits.ts b/ee/packages/license/src/validation/validateLicenseLimits.ts index 168effe6a250..0bc8223a74b0 100644 --- a/ee/packages/license/src/validation/validateLicenseLimits.ts +++ b/ee/packages/license/src/validation/validateLicenseLimits.ts @@ -1,4 +1,4 @@ -import type { ILicenseV3 } from '../definition/ILicenseV3'; +import type { ILicenseV3, LicenseLimitKind } from '../definition/ILicenseV3'; import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; @@ -9,10 +9,11 @@ export async function validateLicenseLimits( this: LicenseManager, license: ILicenseV3, behaviorFilter: (behavior: LicenseBehavior) => boolean, + limitFilter: (limit: LicenseLimitKind) => boolean, ): Promise { const { limits } = license; - const limitKeys = Object.keys(limits) as (keyof ILicenseV3['limits'])[]; + const limitKeys = (Object.keys(limits) as LicenseLimitKind[]).filter(limitFilter || (() => true)); return ( await Promise.all( limitKeys.map(async (limitKey) => {