Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/license-validate-preview.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion apps/meteor/ee/server/api/licenses.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
73 changes: 68 additions & 5 deletions ee/packages/license/src/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
LicenseBehavior,
LicenseInfo,
LicenseValidationOptions,
LicenseValidationResult,
LimitContext,
LicenseModule,
} from '@rocket.chat/core-typings';
Expand Down Expand Up @@ -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<LicenseEvents> {
abstract validateFormat: typeof validateFormat;

Expand Down Expand Up @@ -267,7 +277,7 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
}

const validationResult = await runValidation.call(this, this._license, {
behaviors: ['invalidate_license', 'start_fair_policy', 'prevent_installation', 'disable_modules'],
behaviors: licenseValidationBehaviors,
...options,
});

Expand Down Expand Up @@ -312,6 +322,59 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
}
}

/**
* 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<LicenseValidationResult> {
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<boolean> {
if (!(await validateFormat(encryptedLicense))) {
throw new InvalidLicenseError();
Expand Down Expand Up @@ -408,10 +471,10 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {

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
Expand All @@ -426,9 +489,9 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
'prevent_action',
]);

this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh);
this.shouldPreventActionResults.set(limit, fresh);

return [limit as LicenseLimitKind, fresh];
return [limit, fresh];
}),
);

Expand Down
95 changes: 95 additions & 0 deletions ee/packages/license/src/validateLicenseForPreview.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
33 changes: 33 additions & 0 deletions packages/core-typings/src/license/LicenseValidationResult.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
1 change: 1 addition & 0 deletions packages/core-typings/src/license/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 21 additions & 1 deletion packages/rest-typings/src/v1/licenses.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -36,6 +36,23 @@ const licensesInfoPropsSchema = {

export const isLicensesInfoProps = ajvQuery.compile<licensesInfoProps>(licensesInfoPropsSchema);

type licensesValidateProps = {
license: string;
};

const licensesValidatePropsSchema = {
type: 'object',
properties: {
license: {
type: 'string',
},
},
required: ['license'],
additionalProperties: false,
};

export const isLicensesValidateProps = ajv.compile<licensesValidateProps>(licensesValidatePropsSchema);

export type LicensesEndpoints = {
'/v1/licenses.info': {
GET: (params: licensesInfoProps) => {
Expand All @@ -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 };
};
Expand Down
Loading