Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: license add-ons (external modules) #33433

Merged
merged 34 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9f7d4de
Change license types to accept any string as a module name
d-gubert Oct 3, 2024
f11daa7
Change references to modules in the license package to accept any string
d-gubert Oct 3, 2024
fadfd4e
Include test add-on in license unit test
d-gubert Oct 3, 2024
16ea5cc
Improve license types and events for external modules
d-gubert Oct 3, 2024
a4e5c6a
Move appEnableCheck to canEnableApp
d-gubert Sep 27, 2024
22b541f
When an external module becomes invalid, disable any apps that depend…
d-gubert Oct 3, 2024
896ded1
Fix getModuleDefinition function
d-gubert Oct 4, 2024
5637628
Fix code check
d-gubert Oct 4, 2024
0cad25f
Add external module to test
d-gubert Oct 4, 2024
f9a1ded
Prevent error on startup if app can't be enabled
d-gubert Oct 4, 2024
b996180
Fix external module event trigger
d-gubert Oct 4, 2024
d0d783c
Merge branch 'develop' into feat/license-addons
d-gubert Oct 4, 2024
ecc58b2
Increase coverage for PR
d-gubert Oct 4, 2024
44cb166
Increase MOAR
d-gubert Oct 4, 2024
1cad980
Add changeset
d-gubert Oct 4, 2024
c76c2d9
Return external modules on getInfo
d-gubert Oct 7, 2024
9396e26
Fix loop definition in orchestrator
d-gubert Oct 7, 2024
21440a9
Refactor LicenseModule type
d-gubert Oct 7, 2024
e6d4943
Refactor type in LicenseInfo
d-gubert Oct 8, 2024
99b8e32
Add unit tests for canEnableApp
d-gubert Oct 9, 2024
3c5a30e
Add unit tests for apps license module change callback
d-gubert Oct 10, 2024
459716e
Log to the server if the app is installed but can't be enabled
d-gubert Oct 10, 2024
9c9e7db
Merge branch 'develop' into feat/license-addons
kodiakhq[bot] Oct 11, 2024
1698ece
Merge branch 'develop' into feat/license-addons
kodiakhq[bot] Oct 11, 2024
fbfc1a7
Merge remote-tracking branch 'origin/develop' into feat/license-addons
d-gubert Oct 17, 2024
de510c2
feat: send message to admins when app is disabled due to add-on (#33534)
d-gubert Oct 17, 2024
36dad68
Merge branch 'develop' into feat/license-addons
d-gubert Oct 17, 2024
3838713
Merge remote-tracking branch 'origin/develop' into feat/license-addons
d-gubert Oct 17, 2024
6a407b2
Merge remote-tracking branch 'origin/develop' into feat/license-addons
d-gubert Oct 17, 2024
6c3635e
Merge remote-tracking branch 'origin/develop' into feat/license-addons
d-gubert Oct 18, 2024
374a43d
Add addon property to App type for frontend usage
d-gubert Oct 18, 2024
75a11c6
Oops
d-gubert Oct 18, 2024
c0d49fb
Fix lint
d-gubert Oct 18, 2024
94ac399
Merge branch 'develop' into feat/license-addons
kodiakhq[bot] Oct 18, 2024
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/three-crews-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/apps-engine': minor
'@rocket.chat/license': minor
'@rocket.chat/meteor': minor
---

Added support for interacting with add-ons issued in the license
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ it('should return an empty array of items if have license and not have permissio
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand All @@ -54,7 +53,6 @@ it('should return auditItems if have license and permissions', async () => {
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down Expand Up @@ -89,7 +87,6 @@ it('should return auditMessages item if have license and can-audit permission',
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down Expand Up @@ -117,7 +114,6 @@ it('should return audiLogs item if have license and can-audit-log permission', a
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ it('should return an empty array if have license and not have permissions', asyn
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand All @@ -54,7 +53,6 @@ it('should return auditItems if have license and permissions', async () => {
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down Expand Up @@ -89,7 +87,6 @@ it('should return auditMessages item if have license and can-audit permission',
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down Expand Up @@ -117,7 +114,6 @@ it('should return audiLogs item if have license and can-audit-log permission', a
// @ts-expect-error: just for testing
grantedModules: [{ module: 'auditing' }],
},
// @ts-expect-error: just for testing
activeModules: ['auditing'],
},
}))
Expand Down
26 changes: 21 additions & 5 deletions apps/meteor/ee/app/license/server/canEnableApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,38 @@ import { License } from '@rocket.chat/license';

import { getInstallationSourceFromAppStorageItem } from '../../../../lib/apps/getInstallationSourceFromAppStorageItem';

export const canEnableApp = async (app: IAppStorageItem): Promise<boolean> => {
d-gubert marked this conversation as resolved.
Show resolved Hide resolved
export const canEnableApp = async (app: IAppStorageItem): Promise<void> => {
if (!(await Apps.isInitialized())) {
return false;
throw new Error('apps-engine-not-initialized');
}

// Migrated apps were installed before the validation was implemented
// so they're always allowed to be enabled
if (app.migrated) {
return true;
return;
}

if (app.info.addon && !License.hasModule(app.info.addon)) {
throw new Error('app-addon-not-valid');
}

const source = getInstallationSourceFromAppStorageItem(app);
switch (source) {
case 'private':
return !(await License.shouldPreventAction('privateApps'));
if (await License.shouldPreventAction('privateApps')) {
throw new Error('license-prevented');
}

break;
default:
return !(await License.shouldPreventAction('marketplaceApps'));
if (await License.shouldPreventAction('marketplaceApps')) {
throw new Error('license-prevented');
}

if (app.marketplaceInfo?.isEnterpriseOnly && !License.hasValidLicense()) {
throw new Error('invalid-license');
}

break;
}
};
36 changes: 9 additions & 27 deletions apps/meteor/ee/server/apps/communication/rest.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager';
import { AppInstallationSource } from '@rocket.chat/apps-engine/server/storage';
import type { IUser, IMessage } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Settings, Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
Expand All @@ -20,7 +18,6 @@ import { i18n } from '../../../../server/lib/i18n';
import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
import { canEnableApp } from '../../../app/license/server/canEnableApp';
import { formatAppInstanceForRest } from '../../../lib/misc/formatAppInstanceForRest';
import { appEnableCheck } from '../marketplace/appEnableCheck';
import { notifyAppInstall } from '../marketplace/appInstall';
import type { AppServerOrchestrator } from '../orchestrator';
import { Apps } from '../orchestrator';
Expand Down Expand Up @@ -418,9 +415,13 @@ export class AppsRestApi {

void notifyAppInstall(orchestrator.getMarketplaceUrl() as string, 'install', info);

if (await canEnableApp(aff.getApp().getStorageItem())) {
try {
await canEnableApp(aff.getApp().getStorageItem());

const success = await manager.enable(info.id);
info.status = success ? AppStatus.AUTO_ENABLED : info.status;
} catch (error) {
// should report the error?
tiagoevanp marked this conversation as resolved.
Show resolved Hide resolved
d-gubert marked this conversation as resolved.
Show resolved Hide resolved
}

void orchestrator.getNotifier().appAdded(info.id);
Expand Down Expand Up @@ -1157,33 +1158,14 @@ export class AppsRestApi {
return API.v1.notFound(`No App found by the id of: ${appId}`);
}

const storedApp = prl.getStorageItem();
const { installationSource, marketplaceInfo } = storedApp;

if (!License.hasValidLicense() && installationSource === AppInstallationSource.MARKETPLACE) {
if (AppStatusUtils.isEnabled(status)) {
try {
const baseUrl = orchestrator.getMarketplaceUrl() as string;
const headers = getDefaultHeaders();
const { version } = prl.getInfo();

await appEnableCheck({
baseUrl,
headers,
appId,
version,
marketplaceInfo,
status,
logger: orchestrator.getRocketChatLogger(),
});
} catch (error: any) {
return API.v1.failure(error.message);
await canEnableApp(prl.getStorageItem());
} catch (error: unknown) {
return API.v1.failure((error as Error).message);
}
}

if (AppStatusUtils.isEnabled(status) && !(await canEnableApp(storedApp))) {
return API.v1.failure('Enabled apps have been maxed out');
}

const result = await manager.changeStatus(prl.getID(), status);
return API.v1.success({ status: result.getStatus() });
},
Expand Down
42 changes: 0 additions & 42 deletions apps/meteor/ee/server/apps/marketplace/appEnableCheck.ts

This file was deleted.

13 changes: 5 additions & 8 deletions apps/meteor/ee/server/apps/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,13 @@ export class AppServerOrchestrator {
/* eslint-disable no-await-in-loop */
// This needs to happen sequentially to keep track of app limits
for (const app of apps) {
const canEnable = await canEnableApp(app.getStorageItem());
try {
await canEnableApp(app.getStorageItem());

if (!canEnable) {
this._rocketchatLogger.warn(`App "${app.getInfo().name}" can't be enabled due to CE limits.`);
// We need to continue as the limits are applied depending on the app installation source
// i.e. if one limit is hit, we can't break the loop as the following apps might still be valid
continue;
await this.getManager().loadOne(app.getID());
} catch (error) {
this._rocketchatLogger.warn(`App "${app.getInfo().name}" could not be enabled: `, error.message);
pierre-lehnen-rc marked this conversation as resolved.
Show resolved Hide resolved
}

await this.getManager().loadOne(app.getID());
}
/* eslint-enable no-await-in-loop */

Expand Down
30 changes: 30 additions & 0 deletions apps/meteor/ee/server/startup/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { License } from '@rocket.chat/license';
import { Meteor } from 'meteor/meteor';

import { Apps } from '../apps';

Meteor.startup(() => {
async function disableAppsCallback() {
void Apps.disableApps();
}

License.onInvalidateLicense(disableAppsCallback);
License.onRemoveLicense(disableAppsCallback);
// Disable apps that depend on add-ons (external modules) if they are invalidated
License.onModule(async ({ module, external, valid }) => {
if (!external || valid) return;
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved

const enabledApps = await Apps.installedApps({ enabled: true });

if (!enabledApps) return;

await Promise.all(
enabledApps.map(async (app) => {
if (app.getInfo().addon !== module) return;

await Apps.getManager()?.disable(app.getID(), AppStatus.DISABLED, false);
}),
);
});
d-gubert marked this conversation as resolved.
Show resolved Hide resolved
});
1 change: 0 additions & 1 deletion apps/meteor/ee/server/startup/apps/index.ts

This file was deleted.

9 changes: 0 additions & 9 deletions apps/meteor/ee/server/startup/apps/trialExpiration.ts

This file was deleted.

4 changes: 3 additions & 1 deletion ee/packages/license/__tests__/setLicense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,18 @@ describe('License set license procedures', () => {
const mocked = new MockedLicenseBuilder();
const oldToken = await mocked.sign();

const newToken = await mocked.withGratedModules(['livechat-enterprise']).sign();
const newToken = await mocked.withGratedModules(['livechat-enterprise', 'chat.rocket.test-addon']).sign();

await expect(license.setLicense(oldToken)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);

expect(license.hasModule('livechat-enterprise')).toBe(false);
expect(license.hasModule('chat.rocket.test-addon')).toBe(false);

await expect(license.setLicense(newToken)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);
expect(license.hasModule('livechat-enterprise')).toBe(true);
expect(license.hasModule('chat.rocket.test-addon')).toBe(true);
});

it('should call a validated event after set a valid license', async () => {
Expand Down
25 changes: 18 additions & 7 deletions ee/packages/license/src/MockedLicenseBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { ILicenseTag, ILicenseV3, LicenseLimit, LicenseModule, LicensePeriod, Timestamp } from '@rocket.chat/core-typings';
import {
CoreModules,
type GrantedModules,
type ILicenseTag,
type ILicenseV3,
type LicenseLimit,
type LicenseModule,
type LicensePeriod,
type Timestamp,
} from '@rocket.chat/core-typings';

import { encrypt } from './token';

Expand Down Expand Up @@ -163,9 +172,7 @@ export class MockedLicenseBuilder {
return this;
}

grantedModules: {
module: LicenseModule;
}[] = [];
grantedModules: GrantedModules = [];

limits: {
activeUsers?: LicenseLimit[];
Expand All @@ -190,13 +197,17 @@ export class MockedLicenseBuilder {
return this;
}

public withGratedModules(modules: LicenseModule[]): this {
public withGratedModules(modules: string[]): this {
this.grantedModules = this.grantedModules ?? [];
this.grantedModules.push(...modules.map((module) => ({ module })));
this.grantedModules.push(
...(modules.map((module) =>
CoreModules.includes(module as LicenseModule) ? { module } : { module, external: true },
) as GrantedModules),
);
return this;
}

withNoGratedModules(modules: LicenseModule[]): this {
withNoGratedModules(modules: string[]): this {
this.grantedModules = this.grantedModules ?? [];
d-gubert marked this conversation as resolved.
Show resolved Hide resolved
this.grantedModules = this.grantedModules.filter(({ module }) => !modules.includes(module));
return this;
Expand Down
Loading
Loading