feat(core): Add license support to n8n (#4566)

* add sdk

* add license manager

* type fix

* add basic func

* store to db

* update default

* activate license

* add sharing flag

* fix setup

* clear license

* update conosle log to info

* refactor

* use npm dependency

* update error logs

* add simple test

* add license tests

* update tests

* update pnpm package

* fix error handling types

* Update packages/cli/src/config/schema.ts

Co-authored-by: Cornelius Suermann <cornelius@n8n.io>

* make feature enum

* add warning

* update sdk

* Update packages/cli/src/config/schema.ts

Co-authored-by: Cornelius Suermann <cornelius@n8n.io>

Co-authored-by: Cornelius Suermann <cornelius@n8n.io>
This commit is contained in:
Mutasem Aldmour 2022-11-21 15:41:24 +01:00 committed by GitHub
parent a9bdc0bbfe
commit 30e5d3d04c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 328 additions and 2 deletions

View file

@ -103,12 +103,13 @@
"tsconfig-paths": "^3.14.1" "tsconfig-paths": "^3.14.1"
}, },
"dependencies": { "dependencies": {
"@n8n_io/license-sdk": "^1.3.4",
"@oclif/command": "^1.8.16", "@oclif/command": "^1.8.16",
"@oclif/core": "^1.16.4", "@oclif/core": "^1.16.4",
"@oclif/errors": "^1.3.6", "@oclif/errors": "^1.3.6",
"@rudderstack/rudder-sdk-node": "1.0.6", "@rudderstack/rudder-sdk-node": "1.0.6",
"@sentry/node": "^7.17.3",
"@sentry/integrations": "^7.17.3", "@sentry/integrations": "^7.17.3",
"@sentry/node": "^7.17.3",
"axios": "^0.21.1", "axios": "^0.21.1",
"basic-auth": "^2.0.1", "basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

121
packages/cli/src/License.ts Normal file
View file

@ -0,0 +1,121 @@
import { LicenseManager, TLicenseContainerStr } from '@n8n_io/license-sdk';
import { ILogger } from 'n8n-workflow';
import { getLogger } from './Logger';
import config from '@/config';
import * as Db from '@/Db';
import { LICENSE_FEATURES, SETTINGS_LICENSE_CERT_KEY } from './constants';
async function loadCertStr(): Promise<TLicenseContainerStr> {
const databaseSettings = await Db.collections.Settings.findOne({
where: {
key: SETTINGS_LICENSE_CERT_KEY,
},
});
return databaseSettings?.value ?? '';
}
async function saveCertStr(value: TLicenseContainerStr): Promise<void> {
await Db.collections.Settings.upsert(
{
key: SETTINGS_LICENSE_CERT_KEY,
value,
loadOnStartup: false,
},
['key'],
);
}
export class License {
private logger: ILogger;
private manager: LicenseManager | undefined;
constructor() {
this.logger = getLogger();
}
async init(instanceId: string, version: string) {
if (this.manager) {
return;
}
const server = config.getEnv('license.serverUrl');
const autoRenewEnabled = config.getEnv('license.autoRenewEnabled');
const autoRenewOffset = config.getEnv('license.autoRenewOffset');
try {
this.manager = new LicenseManager({
server,
tenantId: 1,
productIdentifier: `n8n-${version}`,
autoRenewEnabled,
autoRenewOffset,
logger: this.logger,
loadCertStr,
saveCertStr,
deviceFingerprint: () => instanceId,
});
await this.manager.initialize();
} catch (e: unknown) {
if (e instanceof Error) {
this.logger.error('Could not initialize license manager sdk', e);
}
}
}
async activate(activationKey: string): Promise<void> {
if (!this.manager) {
return;
}
if (this.manager.isValid()) {
return;
}
try {
await this.manager.activate(activationKey);
} catch (e) {
if (e instanceof Error) {
this.logger.error('Could not activate license', e);
}
}
}
async renew() {
if (!this.manager) {
return;
}
try {
await this.manager.renew();
} catch (e) {
if (e instanceof Error) {
this.logger.error('Could not renew license', e);
}
}
}
isFeatureEnabled(feature: string): boolean {
if (!this.manager) {
return false;
}
return this.manager.hasFeatureEnabled(feature);
}
isSharingEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.SHARING);
}
}
let licenseInstance: License | undefined;
export function getLicense(): License {
if (licenseInstance === undefined) {
licenseInstance = new License();
}
return licenseInstance;
}

View file

@ -160,6 +160,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { ResponseError } from '@/ResponseHelper'; import { ResponseError } from '@/ResponseHelper';
import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { setupErrorMiddleware } from '@/ErrorReporting'; import { setupErrorMiddleware } from '@/ErrorReporting';
import { getLicense } from '@/License';
require('body-parser-xml')(bodyParser); require('body-parser-xml')(bodyParser);
@ -384,6 +385,16 @@ class App {
return this.frontendSettings; return this.frontendSettings;
} }
async initLicense(): Promise<void> {
const license = getLicense();
await license.init(this.frontendSettings.instanceId, this.frontendSettings.versionCli);
const activationKey = config.getEnv('license.activationKey');
if (activationKey) {
await license.activate(activationKey);
}
}
async config(): Promise<void> { async config(): Promise<void> {
const enableMetrics = config.getEnv('endpoints.metrics.enable'); const enableMetrics = config.getEnv('endpoints.metrics.enable');
let register: Registry; let register: Registry;
@ -406,6 +417,8 @@ class App {
await this.externalHooks.run('frontend.settings', [this.frontendSettings]); await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
await this.initLicense();
const excludeEndpoints = config.getEnv('security.excludeEndpoints'); const excludeEndpoints = config.getEnv('security.excludeEndpoints');
const ignoredEndpoints = [ const ignoredEndpoints = [

View file

@ -13,6 +13,7 @@ import { Role } from '@db/entities/Role';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest } from '@/requests';
import config from '@/config'; import config from '@/config';
import { getWebhookBaseUrl } from '../WebhookHelpers'; import { getWebhookBaseUrl } from '../WebhookHelpers';
import { getLicense } from '@/License';
import { WhereClause } from '@/Interfaces'; import { WhereClause } from '@/Interfaces';
export async function getWorkflowOwner(workflowId: string | number): Promise<User> { export async function getWorkflowOwner(workflowId: string | number): Promise<User> {
@ -41,7 +42,11 @@ export function isUserManagementEnabled(): boolean {
} }
export function isSharingEnabled(): boolean { export function isSharingEnabled(): boolean {
return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing'); const license = getLicense();
return (
isUserManagementEnabled() &&
(config.getEnv('enterprise.features.sharing') || license.isSharingEnabled())
);
} }
export function isUserManagementDisabled(): boolean { export function isUserManagementDisabled(): boolean {

View file

@ -0,0 +1,42 @@
import { Command } from '@oclif/command';
import { LoggerProxy } from 'n8n-workflow';
import * as Db from '@/Db';
import { getLogger } from '@/Logger';
import { SETTINGS_LICENSE_CERT_KEY } from '@/constants';
export class ClearLicenseCommand extends Command {
static description = 'Clear license';
static examples = [`$ n8n clear:license`];
async run() {
const logger = getLogger();
LoggerProxy.init(logger);
try {
await Db.init();
console.info('Clearing license from database.');
await Db.collections.Settings.delete({
key: SETTINGS_LICENSE_CERT_KEY,
});
console.info('Done. Restart n8n to take effect.');
} catch (e: unknown) {
console.error('Error updating database. See log messages for details.');
logger.error('\nGOT ERROR');
logger.info('====================================');
if (e instanceof Error) {
logger.error(e.message);
if (e.stack) {
logger.error(e.stack);
}
}
this.exit(1);
}
this.exit();
}
}

View file

@ -987,4 +987,31 @@ export const schema = {
env: 'N8N_ONBOARDING_CALL_PROMPTS_ENABLED', env: 'N8N_ONBOARDING_CALL_PROMPTS_ENABLED',
}, },
}, },
license: {
serverUrl: {
format: String,
default: 'https://license.n8n.io/v1',
env: 'N8N_LICENSE_SERVER_URL',
doc: 'License server url to retrieve license.',
},
autoRenewEnabled: {
format: Boolean,
default: true,
env: 'N8N_LICENSE_AUTO_RENEW_ENABLED',
doc: 'Whether autorenew for licenses is enabled.',
},
autoRenewOffset: {
format: Number,
default: 60 * 60 * 72, // 72 hours
env: 'N8N_LICENSE_AUTO_RENEW_OFFSET',
doc: 'How many seconds before expiry a license should get automatically renewed. ',
},
activationKey: {
format: String,
default: '',
env: 'N8N_LICENSE_ACTIVATION_KEY',
doc: 'Activation key to initialize license',
},
},
}; };

View file

@ -41,3 +41,9 @@ export const UNKNOWN_FAILURE_REASON = 'Unknown failure reason';
export const WORKFLOW_REACTIVATE_INITIAL_TIMEOUT = 1000; export const WORKFLOW_REACTIVATE_INITIAL_TIMEOUT = 1000;
export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 180000; export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 180000;
export const SETTINGS_LICENSE_CERT_KEY = 'license.cert';
export enum LICENSE_FEATURES {
SHARING = 'feat:sharing',
}

View file

@ -0,0 +1,77 @@
import { LicenseManager } from '@n8n_io/license-sdk';
import config from '@/config';
import { License } from '@/License';
jest.mock('@n8n_io/license-sdk');
const MOCK_SERVER_URL = 'https://server.com/v1';
const MOCK_RENEW_OFFSET = 259200;
const MOCK_INSTANCE_ID = 'instance-id';
const MOCK_N8N_VERSION = '0.27.0';
const MOCK_ACTIVATION_KEY = 'activation-key';
const MOCK_FEATURE_FLAG = 'feat:mock';
describe('License', () => {
beforeAll(() => {
config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
});
let license;
beforeEach(async () => {
license = new License();
await license.init(MOCK_INSTANCE_ID, MOCK_N8N_VERSION);
});
test('initializes license manager', async () => {
expect(LicenseManager).toHaveBeenCalledWith({
autoRenewEnabled: true,
autoRenewOffset: MOCK_RENEW_OFFSET,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${MOCK_N8N_VERSION}`,
logger: expect.anything(),
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
});
});
test('activates license if current license is not valid', async () => {
LicenseManager.prototype.isValid.mockReturnValue(false);
await license.activate(MOCK_ACTIVATION_KEY);
expect(LicenseManager.prototype.isValid).toHaveBeenCalled();
expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY);
});
test('does not activate license if current license is valid', async () => {
LicenseManager.prototype.isValid.mockReturnValue(true);
await license.activate(MOCK_ACTIVATION_KEY);
expect(LicenseManager.prototype.isValid).toHaveBeenCalled();
expect(LicenseManager.prototype.activate).not.toHaveBeenCalledWith();
});
test('renews license', async () => {
await license.renew();
expect(LicenseManager.prototype.renew).toHaveBeenCalled();
});
test('check if feature is enabled', async () => {
await license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
test('check if sharing feature is enabled', async () => {
await license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
});

View file

@ -91,6 +91,7 @@ importers:
packages/cli: packages/cli:
specifiers: specifiers:
'@apidevtools/swagger-cli': 4.0.0 '@apidevtools/swagger-cli': 4.0.0
'@n8n_io/license-sdk': ^1.3.4
'@oclif/command': ^1.8.16 '@oclif/command': ^1.8.16
'@oclif/core': ^1.16.4 '@oclif/core': ^1.16.4
'@oclif/dev-cli': ^1.22.2 '@oclif/dev-cli': ^1.22.2
@ -203,6 +204,7 @@ importers:
winston: ^3.3.3 winston: ^3.3.3
yamljs: ^0.3.0 yamljs: ^0.3.0
dependencies: dependencies:
'@n8n_io/license-sdk': 1.3.4
'@oclif/command': 1.8.18_@oclif+config@1.18.5 '@oclif/command': 1.8.18_@oclif+config@1.18.5
'@oclif/core': 1.16.6 '@oclif/core': 1.16.6
'@oclif/errors': 1.3.6 '@oclif/errors': 1.3.6
@ -3306,6 +3308,18 @@ packages:
resolution: {integrity: sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==} resolution: {integrity: sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==}
dev: true dev: true
/@n8n_io/license-sdk/1.3.4:
resolution: {integrity: sha512-t/J228ftRwTbuCYTqMtjRjfqeNE8Aq0DErYFZS5N9EbX5brNIzWbgGio+25utoI9t31xRPwpviDF+e0kPz8Fvg==}
engines: {node: '>=14.0.0', npm: '>=7.10.0'}
dependencies:
axios: 1.1.3
crypto-js: 4.1.1
node-machine-id: 1.1.12
node-rsa: 1.1.1
transitivePeerDependencies:
- debug
dev: false
/@n8n_io/riot-tmpl/1.0.1: /@n8n_io/riot-tmpl/1.0.1:
resolution: {integrity: sha512-+ig7/rafN3LGthGEi8fs1N5XxPndmRq5YAX92DWOar9mrMDrYyIjK5XAQaTnTMDQgmKKllrAl+bVRmQXKcLFuw==} resolution: {integrity: sha512-+ig7/rafN3LGthGEi8fs1N5XxPndmRq5YAX92DWOar9mrMDrYyIjK5XAQaTnTMDQgmKKllrAl+bVRmQXKcLFuw==}
dependencies: dependencies:
@ -7633,6 +7647,16 @@ packages:
- debug - debug
dev: false dev: false
/axios/1.1.3:
resolution: {integrity: sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==}
dependencies:
follow-redirects: 1.15.2_debug@3.2.7
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
dev: false
/babel-core/7.0.0-bridge.0_@babel+core@7.19.3: /babel-core/7.0.0-bridge.0_@babel+core@7.19.3:
resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==}
peerDependencies: peerDependencies:
@ -16077,6 +16101,10 @@ packages:
vm-browserify: 1.1.2 vm-browserify: 1.1.2
dev: true dev: true
/node-machine-id/1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
dev: false
/node-notifier/10.0.1: /node-notifier/10.0.1:
resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==}
dependencies: dependencies:
@ -16092,6 +16120,12 @@ packages:
resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==}
dev: true dev: true
/node-rsa/1.1.1:
resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==}
dependencies:
asn1: 0.2.6
dev: false
/node-ssh/12.0.5: /node-ssh/12.0.5:
resolution: {integrity: sha512-uN2GTGdBRUUKkZmcNBr9OM+xKL6zq74emnkSyb1TshBdVWegj3boue6QallQeqZzo7YGVheP5gAovUL+8hZSig==} resolution: {integrity: sha512-uN2GTGdBRUUKkZmcNBr9OM+xKL6zq74emnkSyb1TshBdVWegj3boue6QallQeqZzo7YGVheP5gAovUL+8hZSig==}
engines: {node: '>= 10'} engines: {node: '>= 10'}