diff --git a/packages/cli/package.json b/packages/cli/package.json index 14c26f6d08df6..c872eee78b98a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -97,6 +97,7 @@ "@types/replacestream": "^4.0.1", "@types/send": "^0.17.1", "@types/shelljs": "^0.8.11", + "@types/sshpk": "^1.17.1", "@types/superagent": "4.1.13", "@types/swagger-ui-express": "^4.1.3", "@types/syslog-client": "^1.1.2", @@ -142,8 +143,8 @@ "curlconverter": "^3.0.0", "dotenv": "^8.0.0", "express": "^4.18.2", - "express-handlebars": "^7.0.2", "express-async-errors": "^3.1.1", + "express-handlebars": "^7.0.2", "express-openapi-validator": "^4.13.6", "express-prom-bundle": "^6.6.0", "fast-glob": "^3.2.5", @@ -199,6 +200,7 @@ "source-map-support": "^0.5.21", "sqlite3": "^5.1.6", "sse-channel": "^4.0.0", + "sshpk": "^1.17.0", "swagger-ui-express": "^4.3.0", "syslog-client": "^1.1.1", "typedi": "^0.10.0", diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 18673887ac302..d1f2015187496 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -128,7 +128,7 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); } - isVersionControlEnabled() { + isVersionControlLicensed() { return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 217ba6b9c076d..bdce01787f3bd 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -160,7 +160,9 @@ import { variablesController } from './environments/variables.controller'; import { LdapManager } from './Ldap/LdapManager.ee'; import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers'; import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; -import { isVersionControlEnabled } from './environment/versionControl/versionControlHelper'; +import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper'; +import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee'; +import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee'; const exec = promisify(callbackExec); @@ -356,7 +358,7 @@ class Server extends AbstractServer { saml: isSamlLicensed(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), variables: isVariablesEnabled(), - versionControl: isVersionControlEnabled(), + versionControl: isVersionControlLicensed(), }); if (isLdapEnabled()) { @@ -393,6 +395,7 @@ class Server extends AbstractServer { const mailer = Container.get(UserManagementMailer); const postHog = this.postHog; const samlService = Container.get(SamlService); + const versionControlService = Container.get(VersionControlService); const controllers: object[] = [ new EventBusController(), @@ -421,6 +424,7 @@ class Server extends AbstractServer { postHog, }), new SamlController(samlService), + new VersionControlController(versionControlService), ]; if (isLdapEnabled()) { @@ -545,12 +549,10 @@ class Server extends AbstractServer { // initialize SamlService if it is licensed, even if not enabled, to // set up the initial environment - if (isSamlLicensed()) { - try { - await Container.get(SamlService).init(); - } catch (error) { - LoggerProxy.error(`SAML initialization failed: ${error.message}`); - } + try { + await Container.get(SamlService).init(); + } catch (error) { + LoggerProxy.warn(`SAML initialization failed: ${error.message}`); } // ---------------------------------------- @@ -559,6 +561,18 @@ class Server extends AbstractServer { this.app.use(`/${this.restEndpoint}/variables`, variablesController); + // ---------------------------------------- + // Version Control + // ---------------------------------------- + + // initialize SamlService if it is licensed, even if not enabled, to + // set up the initial environment + try { + await Container.get(VersionControlService).init(); + } catch (error) { + LoggerProxy.warn(`Version Control initialization failed: ${error.message}`); + } + // ---------------------------------------- // Returns parameter values which normally get loaded from an external API or diff --git a/packages/cli/src/environment/versionControl/versionControlHelper.ee.ts b/packages/cli/src/environment/versionControl/versionControlHelper.ee.ts new file mode 100644 index 0000000000000..b3857120db144 --- /dev/null +++ b/packages/cli/src/environment/versionControl/versionControlHelper.ee.ts @@ -0,0 +1,18 @@ +import Container from 'typedi'; +import { License } from '../../License'; +import { generateKeyPairSync } from 'crypto'; + +export function isVersionControlEnabled() { + const license = Container.get(License); + return license.isVersionControlLicensed(); +} + +export async function generateSshKeyPair() { + const keyPair = generateKeyPairSync('ed25519', { + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + publicKeyEncoding: { format: 'pem', type: 'spki' }, + }); + + console.log(keyPair.privateKey); + console.log(keyPair.publicKey); +} diff --git a/packages/cli/src/environment/versionControl/versionControlHelper.ts b/packages/cli/src/environment/versionControl/versionControlHelper.ts deleted file mode 100644 index 295e24526afdd..0000000000000 --- a/packages/cli/src/environment/versionControl/versionControlHelper.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Container from 'typedi'; -import { License } from '../../License'; - -export function isVersionControlEnabled() { - const license = Container.get(License); - return license.isVersionControlEnabled(); -} diff --git a/packages/cli/src/environments/versionControl/constants.ts b/packages/cli/src/environments/versionControl/constants.ts new file mode 100644 index 0000000000000..dc415672beae8 --- /dev/null +++ b/packages/cli/src/environments/versionControl/constants.ts @@ -0,0 +1 @@ +export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl'; diff --git a/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts b/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts new file mode 100644 index 0000000000000..55a5e2122e127 --- /dev/null +++ b/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts @@ -0,0 +1,34 @@ +import type { RequestHandler } from 'express'; +import type { AuthenticatedRequest } from '@/requests'; +import { + isVersionControlLicensed, + isVersionControlLicensedAndEnabled, +} from '../versionControlHelper'; + +export const versionControlLicensedOwnerMiddleware: RequestHandler = ( + req: AuthenticatedRequest, + res, + next, +) => { + if (isVersionControlLicensed() && req.user?.globalRole.name === 'owner') { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; + +export const versionControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => { + if (isVersionControlLicensedAndEnabled()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; + +export const versionControlLicensedMiddleware: RequestHandler = (req, res, next) => { + if (isVersionControlLicensed()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; diff --git a/packages/cli/src/environments/versionControl/types/keyPair.ts b/packages/cli/src/environments/versionControl/types/keyPair.ts new file mode 100644 index 0000000000000..239406ec332d1 --- /dev/null +++ b/packages/cli/src/environments/versionControl/types/keyPair.ts @@ -0,0 +1,4 @@ +export interface KeyPair { + privateKey: string; + publicKey: string; +} diff --git a/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts b/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts new file mode 100644 index 0000000000000..ff2becc5d48fb --- /dev/null +++ b/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class VersionControlPreferences { + @IsString() + privateKey: string; + + @IsString() + publicKey: string; +} diff --git a/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts b/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts new file mode 100644 index 0000000000000..fd3d38a6eab4a --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts @@ -0,0 +1,22 @@ +import { Get, RestController } from '../../decorators'; +import { + versionControlLicensedMiddleware, + versionControlLicensedOwnerMiddleware, +} from './middleware/versionControlEnabledMiddleware'; +import { VersionControlService } from './versionControl.service.ee'; + +@RestController('/versionControl') +export class VersionControlController { + constructor(private versionControlService: VersionControlService) {} + + @Get('/preferences', { middlewares: [versionControlLicensedMiddleware] }) + async getPreferences() { + return this.versionControlService.versionControlPreferences; + } + + //TODO: temporary function to generate key and save new pair + @Get('/generateKeyPair', { middlewares: [versionControlLicensedOwnerMiddleware] }) + async generateKeyPair() { + return this.versionControlService.generateAndSaveKeyPair(); + } +} diff --git a/packages/cli/src/environments/versionControl/versionControl.service.ee.ts b/packages/cli/src/environments/versionControl/versionControl.service.ee.ts new file mode 100644 index 0000000000000..3096b5531ddd6 --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControl.service.ee.ts @@ -0,0 +1,70 @@ +import { Service } from 'typedi'; +import { generateSshKeyPair } from './versionControlHelper'; +import { VersionControlPreferences } from './types/versionControlPreferences'; +import { VERSION_CONTROL_PREFERENCES_DB_KEY } from './constants'; +import * as Db from '@/Db'; +import { jsonParse } from 'n8n-workflow'; + +@Service() +export class VersionControlService { + private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences(); + + async init(): Promise { + await this.loadFromDbAndApplyVersionControlPreferences(); + } + + public get versionControlPreferences(): VersionControlPreferences { + return { + ...this._versionControlPreferences, + privateKey: '', + }; + } + + async generateAndSaveKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { + const keyPair = generateSshKeyPair(keyType); + if (keyPair.publicKey && keyPair.privateKey) { + this.setPreferences({ ...keyPair }); + await this.saveSamlPreferencesToDb(); + } + return keyPair; + } + + setPreferences(prefs: Partial) { + this._versionControlPreferences = { + ...this._versionControlPreferences, + ...prefs, + }; + } + + async loadFromDbAndApplyVersionControlPreferences(): Promise< + VersionControlPreferences | undefined + > { + const loadedPrefs = await Db.collections.Settings.findOne({ + where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY }, + }); + if (loadedPrefs) { + try { + const prefs = jsonParse(loadedPrefs.value); + if (prefs) { + this.setPreferences(prefs); + return prefs; + } + } catch {} + } + return; + } + + async saveSamlPreferencesToDb(): Promise { + const settingsValue = JSON.stringify(this._versionControlPreferences); + const result = await Db.collections.Settings.save({ + key: VERSION_CONTROL_PREFERENCES_DB_KEY, + value: settingsValue, + loadOnStartup: true, + }); + if (result) + try { + return jsonParse(result.value); + } catch {} + return; + } +} diff --git a/packages/cli/src/environments/versionControl/versionControlHelper.ts b/packages/cli/src/environments/versionControl/versionControlHelper.ts new file mode 100644 index 0000000000000..4aa9a383c503a --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControlHelper.ts @@ -0,0 +1,53 @@ +import Container from 'typedi'; +import { License } from '../../License'; +import { generateKeyPairSync } from 'crypto'; +import sshpk from 'sshpk'; +import type { KeyPair } from './types/keyPair'; + +export function isVersionControlLicensed() { + const license = Container.get(License); + return license.isVersionControlLicensed(); +} + +export function isVersionControlEnabled() { + // TODO: VERSION CONTROL check if enabled + return true; +} + +export function isVersionControlLicensedAndEnabled() { + return isVersionControlLicensed() && isVersionControlEnabled(); +} + +export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { + const keyPair: KeyPair = { + publicKey: '', + privateKey: '', + }; + let generatedKeyPair: KeyPair; + switch (keyType) { + case 'ed25519': + generatedKeyPair = generateKeyPairSync('ed25519', { + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + publicKeyEncoding: { format: 'pem', type: 'spki' }, + }); + break; + case 'rsa': + generatedKeyPair = generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + break; + } + const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem'); + keyPair.publicKey = keyPublic.toString('ssh'); + const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem'); + keyPair.privateKey = keyPrivate.toString('ssh-private'); + return keyPair; +} diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 846c7541bc827..b1a764c724094 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -302,7 +302,9 @@ export class SamlService { ); } catch (error) { // throw error; - throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); + throw new AuthError( + `SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`, + ); } const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( parsedSamlResponse, diff --git a/packages/cli/test/integration/environments/VersionControl.test.ts b/packages/cli/test/integration/environments/VersionControl.test.ts new file mode 100644 index 0000000000000..d438bf906b3d8 --- /dev/null +++ b/packages/cli/test/integration/environments/VersionControl.test.ts @@ -0,0 +1,38 @@ +import { Container } from 'typedi'; +import type { SuperAgentTest } from 'supertest'; +import type { User } from '@db/entities/User'; +import { License } from '@/License'; +import * as testDb from '../shared/testDb'; +import * as utils from '../shared/utils'; +import { VersionControlService } from '../../../src/environments/versionControl/versionControl.service.ee'; + +let owner: User; +let authOwnerAgent: SuperAgentTest; + +beforeAll(async () => { + Container.get(License).isVersionControlLicensed = () => true; + const app = await utils.initTestServer({ endpointGroups: ['versionControl'] }); + owner = await testDb.createOwner(); + authOwnerAgent = utils.createAuthAgent(app)(owner); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('GET /versionControl/preferences', () => { + test('should return Version Control preferences', async () => { + await Container.get(VersionControlService).generateAndSaveKeyPair(); + await authOwnerAgent + .get('/versionControl/preferences') + .expect(200) + .expect((res) => { + return ( + 'privateKey' in res.body && + 'publicKey' in res.body && + res.body.publicKey.includes('ssh-ed25519') && + res.body.privateKey.includes('BEGIN OPENSSH PRIVATE KEY') + ); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 3fa94aec536db..62dfcd3a874f3 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -23,6 +23,7 @@ type EndpointGroup = | 'nodes' | 'ldap' | 'saml' + | 'versionControl' | 'eventBus' | 'license' | 'variables'; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index a2499b8c8d60c..b3dcd3d2f0ab8 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -82,6 +82,8 @@ import { SamlService } from '@/sso/saml/saml.service.ee'; import { SamlController } from '@/sso/saml/routes/saml.controller.ee'; import { EventBusController } from '@/eventbus/eventBus.controller'; import { License } from '@/License'; +import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee'; +import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee'; export const mockInstance = ( ctor: new (...args: any[]) => T, @@ -202,6 +204,14 @@ export async function initTestServer({ const samlService = Container.get(SamlService); registerController(testServer.app, config, new SamlController(samlService)); break; + case 'versionControl': + const versionControlService = Container.get(VersionControlService); + registerController( + testServer.app, + config, + new VersionControlController(versionControlService), + ); + break; case 'nodes': registerController( testServer.app, diff --git a/packages/cli/test/unit/VersionControl.test.ts b/packages/cli/test/unit/VersionControl.test.ts new file mode 100644 index 0000000000000..6e880859ff298 --- /dev/null +++ b/packages/cli/test/unit/VersionControl.test.ts @@ -0,0 +1,11 @@ +import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper'; + +describe('Version Control', () => { + it('should generate an SSH key pair', () => { + const keyPair = generateSshKeyPair(); + expect(keyPair.privateKey).toBeTruthy(); + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toBeTruthy(); + expect(keyPair.publicKey).toContain('ssh-ed25519'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 650a9bfca1085..210263746e312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,9 @@ importers: sse-channel: specifier: ^4.0.0 version: 4.0.0 + sshpk: + specifier: ^1.17.0 + version: 1.17.0 swagger-ui-express: specifier: ^4.3.0 version: 4.5.0(express@4.18.2) @@ -536,6 +539,9 @@ importers: '@types/shelljs': specifier: ^0.8.11 version: 0.8.11 + '@types/sshpk': + specifier: ^1.17.1 + version: 1.17.1 '@types/superagent': specifier: 4.1.13 version: 4.1.13 @@ -5959,7 +5965,6 @@ packages: resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==} dependencies: '@types/node': 16.18.12 - dev: false /@types/aws4@1.11.2: resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==} @@ -6882,6 +6887,13 @@ packages: '@types/node': 16.18.12 dev: true + /@types/sshpk@1.17.1: + resolution: {integrity: sha512-bOJek/W++DvWRNAeHmpvgX8Q1ypAq4nmeVi3nJ+pjDcMB214S8kSGkxRUw/Uz+zau4VwxcfNp0xUq4s/3DLjLw==} + dependencies: + '@types/asn1': 0.2.0 + '@types/node': 16.18.12 + dev: true + /@types/stack-utils@2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true