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(core): Add license support to n8n #4566

Merged
merged 30 commits into from
Nov 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b0c1b18
add sdk
mutdmour Nov 7, 2022
9565367
add license manager
mutdmour Nov 7, 2022
0fc8f56
Merge branch 'master' of github.com:n8n-io/n8n into N8N-5283-license-sdk
mutdmour Nov 9, 2022
36fa6bd
type fix
mutdmour Nov 9, 2022
4a49a21
add basic func
mutdmour Nov 9, 2022
4feebf2
store to db
mutdmour Nov 9, 2022
d057e8e
update default
mutdmour Nov 9, 2022
342b665
activate license
mutdmour Nov 9, 2022
d3d9c38
add sharing flag
mutdmour Nov 9, 2022
c07b172
fix setup
mutdmour Nov 9, 2022
cce9739
clear license
mutdmour Nov 9, 2022
75bb748
update conosle log to info
mutdmour Nov 9, 2022
9bd05ff
refactor
mutdmour Nov 9, 2022
0c0167c
use npm dependency
mutdmour Nov 9, 2022
b4d5856
update error logs
mutdmour Nov 9, 2022
644c70b
add simple test
mutdmour Nov 9, 2022
e155e3b
add license tests
mutdmour Nov 9, 2022
109af64
update tests
mutdmour Nov 9, 2022
fe94f5c
merge in master
mutdmour Nov 9, 2022
c828c02
update pnpm package
mutdmour Nov 9, 2022
b8a0a67
fix error handling types
mutdmour Nov 9, 2022
4d9d68d
Update packages/cli/src/config/schema.ts
mutdmour Nov 10, 2022
ebc9cc4
make feature enum
mutdmour Nov 10, 2022
4db9bdc
Merge branch 'N8N-5283-license-sdk' of github.com:n8n-io/n8n into N8N…
mutdmour Nov 10, 2022
29bf191
Merge branch 'master' of github.com:n8n-io/n8n into N8N-5283-license-sdk
mutdmour Nov 15, 2022
4bbf784
merge in master
mutdmour Nov 21, 2022
8ec0b6b
add warning
mutdmour Nov 21, 2022
cb2ac94
merge
mutdmour Nov 21, 2022
389f93c
update sdk
mutdmour Nov 21, 2022
d632154
Update packages/cli/src/config/schema.ts
mutdmour Nov 21, 2022
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
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,13 @@
"tsconfig-paths": "^3.14.1"
},
"dependencies": {
"@n8n_io/license-sdk": "^1.3.4",
"@oclif/command": "^1.8.16",
"@oclif/core": "^1.16.4",
"@oclif/errors": "^1.3.6",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@sentry/node": "^7.17.3",
"@sentry/integrations": "^7.17.3",
"@sentry/node": "^7.17.3",
"axios": "^0.21.1",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
Expand Down
121 changes: 121 additions & 0 deletions packages/cli/src/License.ts
Original file line number Diff line number Diff line change
@@ -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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding the cloud-specific checks here? That way we could concentrate these special checks here and not have them scattered across the codebase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which checks do you mean.. the cloud check we need is already here.. checking for config

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh wait I get it.. you want to move config checks here.. not sure it makes sense.. I would have a separate module that manages features..

return this.isFeatureEnabled(LICENSE_FEATURES.SHARING);
}
}

let licenseInstance: License | undefined;

export function getLicense(): License {
mutdmour marked this conversation as resolved.
Show resolved Hide resolved
if (licenseInstance === undefined) {
licenseInstance = new License();
}

return licenseInstance;
}
13 changes: 13 additions & 0 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { ResponseError } from '@/ResponseHelper';
import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { setupErrorMiddleware } from '@/ErrorReporting';
import { getLicense } from '@/License';

require('body-parser-xml')(bodyParser);

Expand Down Expand Up @@ -384,6 +385,16 @@ class App {
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> {
const enableMetrics = config.getEnv('endpoints.metrics.enable');
let register: Registry;
Expand All @@ -406,6 +417,8 @@ class App {

await this.externalHooks.run('frontend.settings', [this.frontendSettings]);

await this.initLicense();

const excludeEndpoints = config.getEnv('security.excludeEndpoints');

const ignoredEndpoints = [
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/UserManagement/UserManagementHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Role } from '@db/entities/Role';
import { AuthenticatedRequest } from '@/requests';
import config from '@/config';
import { getWebhookBaseUrl } from '../WebhookHelpers';
import { getLicense } from '@/License';
import { WhereClause } from '@/Interfaces';

export async function getWorkflowOwner(workflowId: string | number): Promise<User> {
Expand Down Expand Up @@ -41,7 +42,11 @@ export function isUserManagementEnabled(): boolean {
}

export function isSharingEnabled(): boolean {
return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing');
const license = getLicense();
return (
isUserManagementEnabled() &&
(config.getEnv('enterprise.features.sharing') || license.isSharingEnabled())
mutdmour marked this conversation as resolved.
Show resolved Hide resolved
);
}

export function isUserManagementDisabled(): boolean {
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/commands/license/clear.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
27 changes: 27 additions & 0 deletions packages/cli/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,4 +987,31 @@ export const schema = {
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',
},
},
};
6 changes: 6 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ export const UNKNOWN_FAILURE_REASON = 'Unknown failure reason';

export const WORKFLOW_REACTIVATE_INITIAL_TIMEOUT = 1000;
export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 180000;

export const SETTINGS_LICENSE_CERT_KEY = 'license.cert';

export enum LICENSE_FEATURES {
mutdmour marked this conversation as resolved.
Show resolved Hide resolved
SHARING = 'feat:sharing',
}
77 changes: 77 additions & 0 deletions packages/cli/test/unit/License.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading