refactor(core): Move license endpoints to a decorated controller class (no-changelog) (#8074)
This commit is contained in:
committed by
GitHub
parent
63a6e7e034
commit
a63d94f28c
@@ -67,7 +67,6 @@ import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.con
|
||||
import { executionsController } from '@/executions/executions.controller';
|
||||
import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
|
||||
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
||||
import { UserManagementMailer } from '@/UserManagement/email';
|
||||
import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces';
|
||||
import { ActiveExecutions } from '@/ActiveExecutions';
|
||||
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
||||
@@ -79,7 +78,7 @@ import { WaitTracker } from '@/WaitTracker';
|
||||
import { toHttpNodeParameters } from '@/CurlConverterHelper';
|
||||
import { EventBusController } from '@/eventbus/eventBus.controller';
|
||||
import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee';
|
||||
import { licenseController } from './license/license.controller';
|
||||
import { LicenseController } from '@/license/license.controller';
|
||||
import { setupPushServer, setupPushHandler } from '@/push';
|
||||
import { setupAuthMiddlewares } from './middlewares';
|
||||
import { handleLdapInit, isLdapEnabled } from './Ldap/helpers';
|
||||
@@ -249,7 +248,6 @@ export class Server extends AbstractServer {
|
||||
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
|
||||
|
||||
const internalHooks = Container.get(InternalHooks);
|
||||
const mailer = Container.get(UserManagementMailer);
|
||||
const userService = Container.get(UserService);
|
||||
const postHog = this.postHog;
|
||||
const mfaService = Container.get(MfaService);
|
||||
@@ -258,6 +256,7 @@ export class Server extends AbstractServer {
|
||||
new EventBusController(),
|
||||
new EventBusControllerEE(),
|
||||
Container.get(AuthController),
|
||||
Container.get(LicenseController),
|
||||
Container.get(OAuth1CredentialController),
|
||||
Container.get(OAuth2CredentialController),
|
||||
new OwnerController(
|
||||
@@ -423,11 +422,6 @@ export class Server extends AbstractServer {
|
||||
// ----------------------------------------
|
||||
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);
|
||||
|
||||
// ----------------------------------------
|
||||
// License
|
||||
// ----------------------------------------
|
||||
this.app.use(`/${this.restEndpoint}/license`, licenseController);
|
||||
|
||||
// ----------------------------------------
|
||||
// SAML
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -21,4 +21,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'],
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveTriggerCount() {
|
||||
const totalTriggerCount = await this.sum('triggerCount', {
|
||||
active: true,
|
||||
});
|
||||
return totalTriggerCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Container } from 'typedi';
|
||||
import { License } from '@/License';
|
||||
import type { ILicenseReadResponse } from '@/Interfaces';
|
||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
|
||||
export class LicenseService {
|
||||
static async getActiveTriggerCount(): Promise<number> {
|
||||
const totalTriggerCount = await Container.get(WorkflowRepository).sum('triggerCount', {
|
||||
active: true,
|
||||
});
|
||||
return totalTriggerCount ?? 0;
|
||||
}
|
||||
|
||||
// Helper for getting the basic license data that we want to return
|
||||
static async getLicenseData(): Promise<ILicenseReadResponse> {
|
||||
const triggerCount = await LicenseService.getActiveTriggerCount();
|
||||
const license = Container.get(License);
|
||||
const mainPlan = license.getMainPlan();
|
||||
|
||||
return {
|
||||
usage: {
|
||||
executions: {
|
||||
value: triggerCount,
|
||||
limit: license.getTriggerLimit(),
|
||||
warningThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
license: {
|
||||
planId: mainPlan?.productId ?? '',
|
||||
planName: license.getPlanName(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,37 @@
|
||||
import express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Service } from 'typedi';
|
||||
import { Authorized, Get, Post, RequireGlobalScope, RestController } from '@/decorators';
|
||||
import { LicenseRequest } from '@/requests';
|
||||
import { LicenseService } from './license.service';
|
||||
|
||||
import { Logger } from '@/Logger';
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
||||
import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces';
|
||||
import { LicenseService } from './License.service';
|
||||
import { License } from '@/License';
|
||||
import type { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
@Service()
|
||||
@Authorized()
|
||||
@RestController('/license')
|
||||
export class LicenseController {
|
||||
constructor(private readonly licenseService: LicenseService) {}
|
||||
|
||||
export const licenseController = express.Router();
|
||||
|
||||
const OWNER_ROUTES = ['/activate', '/renew'];
|
||||
|
||||
/**
|
||||
* Owner checking
|
||||
*/
|
||||
licenseController.use((req: AuthenticatedRequest, res, next) => {
|
||||
if (OWNER_ROUTES.includes(req.path) && req.user) {
|
||||
if (!req.user.isOwner) {
|
||||
Container.get(Logger).info('Non-owner attempted to activate or renew a license', {
|
||||
userId: req.user.id,
|
||||
});
|
||||
ResponseHelper.sendErrorResponse(
|
||||
res,
|
||||
new UnauthorizedError('Only an instance owner may activate or renew a license'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@Get('/')
|
||||
async getLicenseData() {
|
||||
return this.licenseService.getLicenseData();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /license
|
||||
* Get the license data, usable by everyone
|
||||
*/
|
||||
licenseController.get(
|
||||
'/',
|
||||
ResponseHelper.send(async (): Promise<ILicenseReadResponse> => {
|
||||
return LicenseService.getLicenseData();
|
||||
}),
|
||||
);
|
||||
@Post('/activate')
|
||||
@RequireGlobalScope('license:manage')
|
||||
async activateLicense(req: LicenseRequest.Activate) {
|
||||
const { activationKey } = req.body;
|
||||
await this.licenseService.activateLicense(activationKey);
|
||||
return this.getTokenAndData();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /license/activate
|
||||
* Only usable by the instance owner, activates a license.
|
||||
*/
|
||||
licenseController.post(
|
||||
'/activate',
|
||||
ResponseHelper.send(async (req: LicenseRequest.Activate): Promise<ILicensePostResponse> => {
|
||||
// Call the license manager activate function and tell it to throw an error
|
||||
const license = Container.get(License);
|
||||
try {
|
||||
await license.activate(req.body.activationKey);
|
||||
} catch (e) {
|
||||
const error = e as Error & { errorId?: string };
|
||||
@Post('/renew')
|
||||
@RequireGlobalScope('license:manage')
|
||||
async renewLicense() {
|
||||
await this.licenseService.renewLicense();
|
||||
return this.getTokenAndData();
|
||||
}
|
||||
|
||||
let message = 'Failed to activate license';
|
||||
|
||||
//override specific error messages (to map License Server vocabulary to n8n terms)
|
||||
switch (error.errorId ?? 'UNSPECIFIED') {
|
||||
case 'SCHEMA_VALIDATION':
|
||||
message = 'Activation key is in the wrong format';
|
||||
break;
|
||||
case 'RESERVATION_EXHAUSTED':
|
||||
message =
|
||||
'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it';
|
||||
break;
|
||||
case 'RESERVATION_EXPIRED':
|
||||
message = 'Activation key has expired';
|
||||
break;
|
||||
case 'NOT_FOUND':
|
||||
case 'RESERVATION_CONFLICT':
|
||||
message = 'Activation key not found';
|
||||
break;
|
||||
case 'RESERVATION_DUPLICATE':
|
||||
message = 'Activation key has already been used on this instance';
|
||||
break;
|
||||
default:
|
||||
message += `: ${error.message}`;
|
||||
Container.get(Logger).error(message, { stack: error.stack ?? 'n/a' });
|
||||
}
|
||||
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
|
||||
// Return the read data, plus the management JWT
|
||||
return {
|
||||
managementToken: license.getManagementJwt(),
|
||||
...(await LicenseService.getLicenseData()),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /license/renew
|
||||
* Only usable by instance owner, renews a license
|
||||
*/
|
||||
licenseController.post(
|
||||
'/renew',
|
||||
ResponseHelper.send(async (): Promise<ILicensePostResponse> => {
|
||||
// Call the license manager activate function and tell it to throw an error
|
||||
const license = Container.get(License);
|
||||
try {
|
||||
await license.renew();
|
||||
} catch (e) {
|
||||
const error = e as Error & { errorId?: string };
|
||||
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void Container.get(InternalHooks).onLicenseRenewAttempt({ success: false });
|
||||
if (error instanceof Error) {
|
||||
throw new BadRequestError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void Container.get(InternalHooks).onLicenseRenewAttempt({ success: true });
|
||||
|
||||
// Return the read data, plus the management JWT
|
||||
return {
|
||||
managementToken: license.getManagementJwt(),
|
||||
...(await LicenseService.getLicenseData()),
|
||||
};
|
||||
}),
|
||||
);
|
||||
private async getTokenAndData() {
|
||||
const managementToken = this.licenseService.getManagementJwt();
|
||||
const data = await this.licenseService.getLicenseData();
|
||||
return { ...data, managementToken };
|
||||
}
|
||||
}
|
||||
|
||||
83
packages/cli/src/license/license.service.ts
Normal file
83
packages/cli/src/license/license.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Service } from 'typedi';
|
||||
import { Logger } from '@/Logger';
|
||||
import { License } from '@/License';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
type LicenseError = Error & { errorId?: keyof typeof LicenseErrors };
|
||||
|
||||
export const LicenseErrors = {
|
||||
SCHEMA_VALIDATION: 'Activation key is in the wrong format',
|
||||
RESERVATION_EXHAUSTED:
|
||||
'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it',
|
||||
RESERVATION_EXPIRED: 'Activation key has expired',
|
||||
NOT_FOUND: 'Activation key not found',
|
||||
RESERVATION_CONFLICT: 'Activation key not found',
|
||||
RESERVATION_DUPLICATE: 'Activation key has already been used on this instance',
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class LicenseService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly license: License,
|
||||
private readonly internalHooks: InternalHooks,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
) {}
|
||||
|
||||
async getLicenseData() {
|
||||
const triggerCount = await this.workflowRepository.getActiveTriggerCount();
|
||||
const mainPlan = this.license.getMainPlan();
|
||||
|
||||
return {
|
||||
usage: {
|
||||
executions: {
|
||||
value: triggerCount,
|
||||
limit: this.license.getTriggerLimit(),
|
||||
warningThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
license: {
|
||||
planId: mainPlan?.productId ?? '',
|
||||
planName: this.license.getPlanName(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getManagementJwt(): string {
|
||||
return this.license.getManagementJwt();
|
||||
}
|
||||
|
||||
async activateLicense(activationKey: string) {
|
||||
try {
|
||||
await this.license.activate(activationKey);
|
||||
} catch (e) {
|
||||
const message = this.mapErrorMessage(e as LicenseError, 'activate');
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async renewLicense() {
|
||||
try {
|
||||
await this.license.renew();
|
||||
} catch (e) {
|
||||
const message = this.mapErrorMessage(e as LicenseError, 'renew');
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void this.internalHooks.onLicenseRenewAttempt({ success: false });
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void this.internalHooks.onLicenseRenewAttempt({ success: true });
|
||||
}
|
||||
|
||||
private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') {
|
||||
let message = error.errorId && LicenseErrors[error.errorId];
|
||||
if (!message) {
|
||||
message = `Failed to ${action} license: ${error.message}`;
|
||||
this.logger.error(message, { stack: error.stack ?? 'n/a' });
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const ownerPermissions: Scope[] = [
|
||||
'externalSecret:use',
|
||||
'ldap:manage',
|
||||
'ldap:sync',
|
||||
'license:manage',
|
||||
'logStreaming:manage',
|
||||
'orchestration:read',
|
||||
'orchestration:list',
|
||||
|
||||
@@ -2,14 +2,15 @@ import type RudderStack from '@rudderstack/rudder-sdk-node';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { Container, Service } from 'typedi';
|
||||
import type { ITelemetryTrackProperties } from 'n8n-workflow';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import config from '@/config';
|
||||
import type { IExecutionTrackProperties } from '@/Interfaces';
|
||||
import { Logger } from '@/Logger';
|
||||
import { License } from '@/License';
|
||||
import { LicenseService } from '@/license/License.service';
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success';
|
||||
|
||||
@@ -41,6 +42,7 @@ export class Telemetry {
|
||||
private postHog: PostHogClient,
|
||||
private license: License,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
@@ -107,7 +109,7 @@ export class Telemetry {
|
||||
const pulsePacket = {
|
||||
plan_name_current: this.license.getPlanName(),
|
||||
quota: this.license.getTriggerLimit(),
|
||||
usage: await LicenseService.getActiveTriggerCount(),
|
||||
usage: await this.workflowRepository.getActiveTriggerCount(),
|
||||
source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(),
|
||||
branchName: sourceControlPreferences.branchName,
|
||||
read_only_instance: sourceControlPreferences.branchReadOnly,
|
||||
|
||||
Reference in New Issue
Block a user