mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: External Secrets storage for credentials (#6477)
Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: Romain Minaud <romain.minaud@gmail.com> Co-authored-by: Valya Bullions <valya@n8n.io> Co-authored-by: Csaba Tuncsik <csaba@n8n.io> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
parent
c833078c87
commit
ed927d34b2
|
@ -139,6 +139,7 @@
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"google-timezones-json": "^1.1.0",
|
"google-timezones-json": "^1.1.0",
|
||||||
"handlebars": "4.7.7",
|
"handlebars": "4.7.7",
|
||||||
|
"infisical-node": "^1.3.0",
|
||||||
"inquirer": "^7.0.1",
|
"inquirer": "^7.0.1",
|
||||||
"ioredis": "^5.2.4",
|
"ioredis": "^5.2.4",
|
||||||
"json-diff": "^1.0.6",
|
"json-diff": "^1.0.6",
|
||||||
|
|
|
@ -31,6 +31,7 @@ import type {
|
||||||
IHttpRequestHelper,
|
IHttpRequestHelper,
|
||||||
INodeTypeData,
|
INodeTypeData,
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
ICredentialTestFunctions,
|
ICredentialTestFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
|
@ -342,6 +343,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
* @param {boolean} [raw] Return the data as supplied without defaults or overwrites
|
* @param {boolean} [raw] Return the data as supplied without defaults or overwrites
|
||||||
*/
|
*/
|
||||||
async getDecrypted(
|
async getDecrypted(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
nodeCredentials: INodeCredentialsDetails,
|
nodeCredentials: INodeCredentialsDetails,
|
||||||
type: string,
|
type: string,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
|
@ -356,12 +358,18 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
return decryptedDataOriginal;
|
return decryptedDataOriginal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await additionalData?.secretsHelpers?.waitForInit();
|
||||||
|
|
||||||
|
const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials);
|
||||||
|
|
||||||
return this.applyDefaultsAndOverwrites(
|
return this.applyDefaultsAndOverwrites(
|
||||||
|
additionalData,
|
||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
type,
|
type,
|
||||||
mode,
|
mode,
|
||||||
defaultTimezone,
|
defaultTimezone,
|
||||||
expressionResolveValues,
|
expressionResolveValues,
|
||||||
|
canUseSecrets,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,11 +377,13 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
* Applies credential default data and overwrites
|
* Applies credential default data and overwrites
|
||||||
*/
|
*/
|
||||||
applyDefaultsAndOverwrites(
|
applyDefaultsAndOverwrites(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
decryptedDataOriginal: ICredentialDataDecryptedObject,
|
decryptedDataOriginal: ICredentialDataDecryptedObject,
|
||||||
type: string,
|
type: string,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
defaultTimezone: string,
|
defaultTimezone: string,
|
||||||
expressionResolveValues?: ICredentialsExpressionResolveValues,
|
expressionResolveValues?: ICredentialsExpressionResolveValues,
|
||||||
|
canUseSecrets?: boolean,
|
||||||
): ICredentialDataDecryptedObject {
|
): ICredentialDataDecryptedObject {
|
||||||
const credentialsProperties = this.getCredentialsProperties(type);
|
const credentialsProperties = this.getCredentialsProperties(type);
|
||||||
|
|
||||||
|
@ -395,6 +405,10 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData;
|
decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalKeys = NodeExecuteFunctions.getAdditionalKeys(additionalData, mode, null, {
|
||||||
|
secretsEnabled: canUseSecrets,
|
||||||
|
});
|
||||||
|
|
||||||
if (expressionResolveValues) {
|
if (expressionResolveValues) {
|
||||||
const timezone = expressionResolveValues.workflow.settings.timezone ?? defaultTimezone;
|
const timezone = expressionResolveValues.workflow.settings.timezone ?? defaultTimezone;
|
||||||
|
|
||||||
|
@ -408,7 +422,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
expressionResolveValues.connectionInputData,
|
expressionResolveValues.connectionInputData,
|
||||||
mode,
|
mode,
|
||||||
timezone,
|
timezone,
|
||||||
{},
|
additionalKeys,
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
decryptedData,
|
decryptedData,
|
||||||
|
@ -431,7 +445,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
decryptedData as INodeParameters,
|
decryptedData as INodeParameters,
|
||||||
mode,
|
mode,
|
||||||
defaultTimezone,
|
defaultTimezone,
|
||||||
{},
|
additionalKeys,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
decryptedData,
|
decryptedData,
|
||||||
|
@ -573,10 +587,24 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credentialsDecrypted.data) {
|
if (credentialsDecrypted.data) {
|
||||||
credentialsDecrypted.data = CredentialsOverwrites().applyOverwrite(
|
try {
|
||||||
credentialType,
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
|
||||||
credentialsDecrypted.data,
|
credentialsDecrypted.data = this.applyDefaultsAndOverwrites(
|
||||||
);
|
additionalData,
|
||||||
|
credentialsDecrypted.data,
|
||||||
|
credentialType,
|
||||||
|
'internal' as WorkflowExecuteMode,
|
||||||
|
additionalData.timezone,
|
||||||
|
undefined,
|
||||||
|
user.isOwner,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.debug('Credential test failed', error);
|
||||||
|
return {
|
||||||
|
status: 'Error',
|
||||||
|
message: error.message.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof credentialTestFunction === 'function') {
|
if (typeof credentialTestFunction === 'function') {
|
||||||
|
@ -759,6 +787,36 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
message: 'Connection successful!',
|
message: 'Connection successful!',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
|
||||||
|
if (!nodeCredential.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await Db.collections.SharedCredentials.findOne({
|
||||||
|
where: {
|
||||||
|
role: {
|
||||||
|
scope: 'credential',
|
||||||
|
name: 'owner',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
globalRole: {
|
||||||
|
scope: 'global',
|
||||||
|
name: 'owner',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
id: nodeCredential.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { Authorized, Get, Post, RestController } from '@/decorators';
|
||||||
|
import { ExternalSecretsRequest } from '@/requests';
|
||||||
|
import { NotFoundError } from '@/ResponseHelper';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { ProviderNotFoundError, ExternalSecretsService } from './ExternalSecrets.service.ee';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
@Authorized(['global', 'owner'])
|
||||||
|
@RestController('/external-secrets')
|
||||||
|
export class ExternalSecretsController {
|
||||||
|
constructor(private readonly secretsService: ExternalSecretsService) {}
|
||||||
|
|
||||||
|
@Get('/providers')
|
||||||
|
async getProviders() {
|
||||||
|
return this.secretsService.getProviders();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/providers/:provider')
|
||||||
|
async getProvider(req: ExternalSecretsRequest.GetProvider) {
|
||||||
|
const providerName = req.params.provider;
|
||||||
|
try {
|
||||||
|
return this.secretsService.getProvider(providerName);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ProviderNotFoundError) {
|
||||||
|
throw new NotFoundError(`Could not find provider "${e.providerName}"`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/providers/:provider/test')
|
||||||
|
async testProviderSettings(req: ExternalSecretsRequest.TestProviderSettings, res: Response) {
|
||||||
|
const providerName = req.params.provider;
|
||||||
|
try {
|
||||||
|
const result = await this.secretsService.testProviderSettings(providerName, req.body);
|
||||||
|
if (result.success) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
} else {
|
||||||
|
res.statusCode = 400;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ProviderNotFoundError) {
|
||||||
|
throw new NotFoundError(`Could not find provider "${e.providerName}"`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/providers/:provider')
|
||||||
|
async setProviderSettings(req: ExternalSecretsRequest.SetProviderSettings) {
|
||||||
|
const providerName = req.params.provider;
|
||||||
|
try {
|
||||||
|
await this.secretsService.saveProviderSettings(providerName, req.body, req.user.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ProviderNotFoundError) {
|
||||||
|
throw new NotFoundError(`Could not find provider "${e.providerName}"`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/providers/:provider/connect')
|
||||||
|
async setProviderConnected(req: ExternalSecretsRequest.SetProviderConnected) {
|
||||||
|
const providerName = req.params.provider;
|
||||||
|
try {
|
||||||
|
await this.secretsService.saveProviderConnected(providerName, req.body.connected);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ProviderNotFoundError) {
|
||||||
|
throw new NotFoundError(`Could not find provider "${e.providerName}"`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/providers/:provider/update')
|
||||||
|
async updateProvider(req: ExternalSecretsRequest.UpdateProvider, res: Response) {
|
||||||
|
const providerName = req.params.provider;
|
||||||
|
try {
|
||||||
|
const resp = await this.secretsService.updateProvider(providerName);
|
||||||
|
if (resp) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
} else {
|
||||||
|
res.statusCode = 400;
|
||||||
|
}
|
||||||
|
return { updated: resp };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ProviderNotFoundError) {
|
||||||
|
throw new NotFoundError(`Could not find provider "${e.providerName}"`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/secrets')
|
||||||
|
getSecretNames() {
|
||||||
|
return this.secretsService.getAllSecrets();
|
||||||
|
}
|
||||||
|
}
|
154
packages/cli/src/ExternalSecrets/ExternalSecrets.service.ee.ts
Normal file
154
packages/cli/src/ExternalSecrets/ExternalSecrets.service.ee.ts
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
||||||
|
import type { SecretsProvider } from '@/Interfaces';
|
||||||
|
import type { ExternalSecretsRequest } from '@/requests';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
import { deepCopy } from 'n8n-workflow';
|
||||||
|
import Container, { Service } from 'typedi';
|
||||||
|
import { ExternalSecretsManager } from './ExternalSecretsManager.ee';
|
||||||
|
|
||||||
|
export class ProviderNotFoundError extends Error {
|
||||||
|
constructor(public providerName: string) {
|
||||||
|
super(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ExternalSecretsService {
|
||||||
|
getProvider(providerName: string): ExternalSecretsRequest.GetProviderResponse | null {
|
||||||
|
const providerAndSettings =
|
||||||
|
Container.get(ExternalSecretsManager).getProviderWithSettings(providerName);
|
||||||
|
if (!providerAndSettings) {
|
||||||
|
throw new ProviderNotFoundError(providerName);
|
||||||
|
}
|
||||||
|
const { provider, settings } = providerAndSettings;
|
||||||
|
return {
|
||||||
|
displayName: provider.displayName,
|
||||||
|
name: provider.name,
|
||||||
|
icon: provider.name,
|
||||||
|
state: provider.state,
|
||||||
|
connected: settings.connected,
|
||||||
|
connectedAt: settings.connectedAt,
|
||||||
|
properties: provider.properties,
|
||||||
|
data: this.redact(settings.settings, provider),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProviders() {
|
||||||
|
return Container.get(ExternalSecretsManager)
|
||||||
|
.getProvidersWithSettings()
|
||||||
|
.map(({ provider, settings }) => ({
|
||||||
|
displayName: provider.displayName,
|
||||||
|
name: provider.name,
|
||||||
|
icon: provider.name,
|
||||||
|
state: provider.state,
|
||||||
|
connected: !!settings.connected,
|
||||||
|
connectedAt: settings.connectedAt,
|
||||||
|
data: this.redact(settings.settings, provider),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take data and replace all sensitive values with a sentinel value.
|
||||||
|
// This will replace password fields and oauth data.
|
||||||
|
redact(data: IDataObject, provider: SecretsProvider): IDataObject {
|
||||||
|
const copiedData = deepCopy(data || {});
|
||||||
|
|
||||||
|
const properties = provider.properties;
|
||||||
|
|
||||||
|
for (const dataKey of Object.keys(copiedData)) {
|
||||||
|
// The frontend only cares that this value isn't falsy.
|
||||||
|
if (dataKey === 'oauthTokenData') {
|
||||||
|
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prop = properties.find((v) => v.name === dataKey);
|
||||||
|
if (!prop) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
prop.typeOptions?.password &&
|
||||||
|
(!(copiedData[dataKey] as string).startsWith('=') || prop.noDataExpression)
|
||||||
|
) {
|
||||||
|
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copiedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private unredactRestoreValues(unmerged: any, replacement: any) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
for (const [key, value] of Object.entries(unmerged)) {
|
||||||
|
if (value === CREDENTIAL_BLANKING_VALUE) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
unmerged[key] = replacement[key];
|
||||||
|
} else if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
key in replacement &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
typeof replacement[key] === 'object' &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
replacement[key] !== null
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
this.unredactRestoreValues(value, replacement[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take unredacted data (probably from the DB) and merge it with
|
||||||
|
// redacted data to create an unredacted version.
|
||||||
|
unredact(redactedData: IDataObject, savedData: IDataObject): IDataObject {
|
||||||
|
// Replace any blank sentinel values with their saved version
|
||||||
|
const mergedData = deepCopy(redactedData ?? {});
|
||||||
|
this.unredactRestoreValues(mergedData, savedData);
|
||||||
|
return mergedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveProviderSettings(providerName: string, data: IDataObject, userId: string) {
|
||||||
|
const providerAndSettings =
|
||||||
|
Container.get(ExternalSecretsManager).getProviderWithSettings(providerName);
|
||||||
|
if (!providerAndSettings) {
|
||||||
|
throw new ProviderNotFoundError(providerName);
|
||||||
|
}
|
||||||
|
const { settings } = providerAndSettings;
|
||||||
|
const newData = this.unredact(data, settings.settings);
|
||||||
|
await Container.get(ExternalSecretsManager).setProviderSettings(providerName, newData, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveProviderConnected(providerName: string, connected: boolean) {
|
||||||
|
const providerAndSettings =
|
||||||
|
Container.get(ExternalSecretsManager).getProviderWithSettings(providerName);
|
||||||
|
if (!providerAndSettings) {
|
||||||
|
throw new ProviderNotFoundError(providerName);
|
||||||
|
}
|
||||||
|
await Container.get(ExternalSecretsManager).setProviderConnected(providerName, connected);
|
||||||
|
return this.getProvider(providerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllSecrets(): Record<string, string[]> {
|
||||||
|
return Container.get(ExternalSecretsManager).getAllSecretNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
async testProviderSettings(providerName: string, data: IDataObject) {
|
||||||
|
const providerAndSettings =
|
||||||
|
Container.get(ExternalSecretsManager).getProviderWithSettings(providerName);
|
||||||
|
if (!providerAndSettings) {
|
||||||
|
throw new ProviderNotFoundError(providerName);
|
||||||
|
}
|
||||||
|
const { settings } = providerAndSettings;
|
||||||
|
const newData = this.unredact(data, settings.settings);
|
||||||
|
return Container.get(ExternalSecretsManager).testProviderSettings(providerName, newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProvider(providerName: string) {
|
||||||
|
const providerAndSettings =
|
||||||
|
Container.get(ExternalSecretsManager).getProviderWithSettings(providerName);
|
||||||
|
if (!providerAndSettings) {
|
||||||
|
throw new ProviderNotFoundError(providerName);
|
||||||
|
}
|
||||||
|
return Container.get(ExternalSecretsManager).updateProvider(providerName);
|
||||||
|
}
|
||||||
|
}
|
381
packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts
Normal file
381
packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts
Normal file
|
@ -0,0 +1,381 @@
|
||||||
|
import { SettingsRepository } from '@/databases/repositories';
|
||||||
|
import type {
|
||||||
|
ExternalSecretsSettings,
|
||||||
|
SecretsProvider,
|
||||||
|
SecretsProviderSettings,
|
||||||
|
} from '@/Interfaces';
|
||||||
|
|
||||||
|
import { UserSettings } from 'n8n-core';
|
||||||
|
import Container, { Service } from 'typedi';
|
||||||
|
|
||||||
|
import { AES, enc } from 'crypto-js';
|
||||||
|
import { getLogger } from '@/Logger';
|
||||||
|
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
EXTERNAL_SECRETS_INITIAL_BACKOFF,
|
||||||
|
EXTERNAL_SECRETS_MAX_BACKOFF,
|
||||||
|
EXTERNAL_SECRETS_UPDATE_INTERVAL,
|
||||||
|
} from './constants';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
import { ExternalSecretsProviders } from './ExternalSecretsProviders.ee';
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ExternalSecretsManager {
|
||||||
|
private providers: Record<string, SecretsProvider> = {};
|
||||||
|
|
||||||
|
private initializingPromise?: Promise<void>;
|
||||||
|
|
||||||
|
private cachedSettings: ExternalSecretsSettings = {};
|
||||||
|
|
||||||
|
initialized = false;
|
||||||
|
|
||||||
|
updateInterval: NodeJS.Timer;
|
||||||
|
|
||||||
|
initRetryTimeouts: Record<string, NodeJS.Timer> = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private settingsRepo: SettingsRepository,
|
||||||
|
private license: License,
|
||||||
|
private secretsProviders: ExternalSecretsProviders,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
if (!this.initializingPromise) {
|
||||||
|
this.initializingPromise = new Promise<void>(async (resolve) => {
|
||||||
|
await this.internalInit();
|
||||||
|
this.initialized = true;
|
||||||
|
resolve();
|
||||||
|
this.initializingPromise = undefined;
|
||||||
|
this.updateInterval = setInterval(
|
||||||
|
async () => this.updateSecrets(),
|
||||||
|
EXTERNAL_SECRETS_UPDATE_INTERVAL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.initializingPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
Object.values(this.providers).forEach((p) => {
|
||||||
|
// Disregard any errors as we're shutting down anyway
|
||||||
|
void p.disconnect().catch(() => {});
|
||||||
|
});
|
||||||
|
Object.values(this.initRetryTimeouts).forEach((v) => clearTimeout(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEncryptionKey(): Promise<string> {
|
||||||
|
return UserSettings.getEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptSecretsSettings(value: string, encryptionKey: string): ExternalSecretsSettings {
|
||||||
|
const decryptedData = AES.decrypt(value, encryptionKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decryptedData.toString(enc.Utf8)) as ExternalSecretsSettings;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
'External Secrets Settings could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDecryptedSettings(
|
||||||
|
settingsRepo: SettingsRepository,
|
||||||
|
): Promise<ExternalSecretsSettings | null> {
|
||||||
|
const encryptedSettings = await settingsRepo.getEncryptedSecretsProviderSettings();
|
||||||
|
if (encryptedSettings === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const encryptionKey = await this.getEncryptionKey();
|
||||||
|
return this.decryptSecretsSettings(encryptedSettings, encryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async internalInit() {
|
||||||
|
const settings = await this.getDecryptedSettings(this.settingsRepo);
|
||||||
|
if (!settings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const providers: Array<SecretsProvider | null> = (
|
||||||
|
await Promise.allSettled(
|
||||||
|
Object.entries(settings).map(async ([name, providerSettings]) =>
|
||||||
|
this.initProvider(name, providerSettings),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).map((i) => (i.status === 'rejected' ? null : i.value));
|
||||||
|
this.providers = Object.fromEntries(
|
||||||
|
(providers.filter((p) => p !== null) as SecretsProvider[]).map((s) => [s.name, s]),
|
||||||
|
);
|
||||||
|
this.cachedSettings = settings;
|
||||||
|
await this.updateSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initProvider(
|
||||||
|
name: string,
|
||||||
|
providerSettings: SecretsProviderSettings,
|
||||||
|
currentBackoff = EXTERNAL_SECRETS_INITIAL_BACKOFF,
|
||||||
|
) {
|
||||||
|
const providerClass = this.secretsProviders.getProvider(name);
|
||||||
|
if (!providerClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const provider: SecretsProvider = new providerClass();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provider.init(providerSettings);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Error initializing secrets provider ${provider.displayName} (${provider.name}).`,
|
||||||
|
);
|
||||||
|
this.retryInitWithBackoff(name, currentBackoff);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (providerSettings.connected) {
|
||||||
|
await provider.connect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await provider.disconnect();
|
||||||
|
} catch {}
|
||||||
|
logger.error(
|
||||||
|
`Error initializing secrets provider ${provider.displayName} (${provider.name}).`,
|
||||||
|
);
|
||||||
|
this.retryInitWithBackoff(name, currentBackoff);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryInitWithBackoff(name: string, currentBackoff: number) {
|
||||||
|
if (name in this.initRetryTimeouts) {
|
||||||
|
clearTimeout(this.initRetryTimeouts[name]);
|
||||||
|
delete this.initRetryTimeouts[name];
|
||||||
|
}
|
||||||
|
this.initRetryTimeouts[name] = setTimeout(() => {
|
||||||
|
delete this.initRetryTimeouts[name];
|
||||||
|
if (this.providers[name] && this.providers[name].state !== 'error') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.reloadProvider(name, Math.min(currentBackoff * 2, EXTERNAL_SECRETS_MAX_BACKOFF));
|
||||||
|
}, currentBackoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSecrets() {
|
||||||
|
if (!this.license.isExternalSecretsEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled(
|
||||||
|
Object.entries(this.providers).map(async ([k, p]) => {
|
||||||
|
try {
|
||||||
|
if (this.cachedSettings[k].connected && p.state === 'connected') {
|
||||||
|
await p.update();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error(`Error updating secrets provider ${p.displayName} (${p.name}).`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvider(provider: string): SecretsProvider | undefined {
|
||||||
|
return this.providers[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProvider(provider: string): boolean {
|
||||||
|
return provider in this.providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderNames(): string[] | undefined {
|
||||||
|
return Object.keys(this.providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(provider: string, name: string): IDataObject | undefined {
|
||||||
|
return this.getProvider(provider)?.getSecret(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(provider: string, name: string): boolean {
|
||||||
|
return this.getProvider(provider)?.hasSecret(name) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(provider: string): string[] | undefined {
|
||||||
|
return this.getProvider(provider)?.getSecretNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllSecretNames(): Record<string, string[]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(this.providers).map((provider) => [
|
||||||
|
provider,
|
||||||
|
this.getSecretNames(provider) ?? [],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvidersWithSettings(): Array<{
|
||||||
|
provider: SecretsProvider;
|
||||||
|
settings: SecretsProviderSettings;
|
||||||
|
}> {
|
||||||
|
return Object.entries(this.secretsProviders.getAllProviders()).map(([k, c]) => ({
|
||||||
|
provider: this.getProvider(k) ?? new c(),
|
||||||
|
settings: this.cachedSettings[k] ?? {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderWithSettings(provider: string):
|
||||||
|
| {
|
||||||
|
provider: SecretsProvider;
|
||||||
|
settings: SecretsProviderSettings;
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
const providerConstructor = this.secretsProviders.getProvider(provider);
|
||||||
|
if (!providerConstructor) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: this.getProvider(provider) ?? new providerConstructor(),
|
||||||
|
settings: this.cachedSettings[provider] ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadProvider(provider: string, backoff = EXTERNAL_SECRETS_INITIAL_BACKOFF) {
|
||||||
|
if (provider in this.providers) {
|
||||||
|
await this.providers[provider].disconnect();
|
||||||
|
delete this.providers[provider];
|
||||||
|
}
|
||||||
|
const newProvider = await this.initProvider(provider, this.cachedSettings[provider], backoff);
|
||||||
|
if (newProvider) {
|
||||||
|
this.providers[provider] = newProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setProviderSettings(provider: string, data: IDataObject, userId?: string) {
|
||||||
|
let isNewProvider = false;
|
||||||
|
let settings = await this.getDecryptedSettings(this.settingsRepo);
|
||||||
|
if (!settings) {
|
||||||
|
settings = {};
|
||||||
|
}
|
||||||
|
if (!(provider in settings)) {
|
||||||
|
isNewProvider = true;
|
||||||
|
}
|
||||||
|
settings[provider] = {
|
||||||
|
connected: settings[provider]?.connected ?? false,
|
||||||
|
connectedAt: settings[provider]?.connectedAt ?? new Date(),
|
||||||
|
settings: data,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveAndSetSettings(settings, this.settingsRepo);
|
||||||
|
this.cachedSettings = settings;
|
||||||
|
await this.reloadProvider(provider);
|
||||||
|
|
||||||
|
void this.trackProviderSave(provider, isNewProvider, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setProviderConnected(provider: string, connected: boolean) {
|
||||||
|
let settings = await this.getDecryptedSettings(this.settingsRepo);
|
||||||
|
if (!settings) {
|
||||||
|
settings = {};
|
||||||
|
}
|
||||||
|
settings[provider] = {
|
||||||
|
connected,
|
||||||
|
connectedAt: connected ? new Date() : settings[provider]?.connectedAt ?? null,
|
||||||
|
settings: settings[provider]?.settings ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveAndSetSettings(settings, this.settingsRepo);
|
||||||
|
this.cachedSettings = settings;
|
||||||
|
await this.reloadProvider(provider);
|
||||||
|
await this.updateSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trackProviderSave(vaultType: string, isNew: boolean, userId?: string) {
|
||||||
|
let testResult: [boolean] | [boolean, string] | undefined;
|
||||||
|
try {
|
||||||
|
testResult = await this.getProvider(vaultType)?.test();
|
||||||
|
} catch {}
|
||||||
|
void Container.get(InternalHooks).onExternalSecretsProviderSettingsSaved({
|
||||||
|
user_id: userId,
|
||||||
|
vault_type: vaultType,
|
||||||
|
is_new: isNew,
|
||||||
|
is_valid: testResult?.[0] ?? false,
|
||||||
|
error_message: testResult?.[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptSecretsSettings(settings: ExternalSecretsSettings, encryptionKey: string): string {
|
||||||
|
return AES.encrypt(JSON.stringify(settings), encryptionKey).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAndSetSettings(settings: ExternalSecretsSettings, settingsRepo: SettingsRepository) {
|
||||||
|
const encryptionKey = await this.getEncryptionKey();
|
||||||
|
const encryptedSettings = this.encryptSecretsSettings(settings, encryptionKey);
|
||||||
|
await settingsRepo.saveEncryptedSecretsProviderSettings(encryptedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async testProviderSettings(
|
||||||
|
provider: string,
|
||||||
|
data: IDataObject,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
testState: 'connected' | 'tested' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
let testProvider: SecretsProvider | null = null;
|
||||||
|
try {
|
||||||
|
testProvider = await this.initProvider(provider, {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
settings: data,
|
||||||
|
});
|
||||||
|
if (!testProvider) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
testState: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [success, error] = await testProvider.test();
|
||||||
|
let testState: 'connected' | 'tested' | 'error' = 'error';
|
||||||
|
if (success && this.cachedSettings[provider]?.connected) {
|
||||||
|
testState = 'connected';
|
||||||
|
} else if (success) {
|
||||||
|
testState = 'tested';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
testState,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
testState: 'error',
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (testProvider) {
|
||||||
|
await testProvider.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProvider(provider: string): Promise<boolean> {
|
||||||
|
if (!this.license.isExternalSecretsEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.providers[provider] || this.providers[provider].state !== 'connected') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.providers[provider].update();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { SecretsProvider } from '@/Interfaces';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { InfisicalProvider } from './providers/infisical';
|
||||||
|
import { VaultProvider } from './providers/vault';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ExternalSecretsProviders {
|
||||||
|
providers: Record<string, { new (): SecretsProvider }> = {
|
||||||
|
infisical: InfisicalProvider,
|
||||||
|
vault: VaultProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
getProvider(name: string): { new (): SecretsProvider } | null {
|
||||||
|
return this.providers[name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProvider(name: string) {
|
||||||
|
return name in this.providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllProviders() {
|
||||||
|
return this.providers;
|
||||||
|
}
|
||||||
|
}
|
6
packages/cli/src/ExternalSecrets/constants.ts
Normal file
6
packages/cli/src/ExternalSecrets/constants.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets';
|
||||||
|
export const EXTERNAL_SECRETS_UPDATE_INTERVAL = 5 * 60 * 1000;
|
||||||
|
export const EXTERNAL_SECRETS_INITIAL_BACKOFF = 10 * 1000;
|
||||||
|
export const EXTERNAL_SECRETS_MAX_BACKOFF = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export const EXTERNAL_SECRETS_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { License } from '@/License';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
export function isExternalSecretsEnabled() {
|
||||||
|
const license = Container.get(License);
|
||||||
|
return license.isExternalSecretsEnabled();
|
||||||
|
}
|
153
packages/cli/src/ExternalSecrets/providers/infisical.ts
Normal file
153
packages/cli/src/ExternalSecrets/providers/infisical.ts
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import type { SecretsProvider, SecretsProviderSettings, SecretsProviderState } from '@/Interfaces';
|
||||||
|
import InfisicalClient from 'infisical-node';
|
||||||
|
import { populateClientWorkspaceConfigsHelper } from 'infisical-node/lib/helpers/key';
|
||||||
|
import { getServiceTokenData } from 'infisical-node/lib/api/serviceTokenData';
|
||||||
|
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||||
|
import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
|
||||||
|
|
||||||
|
export interface InfisicalSettings {
|
||||||
|
token: string;
|
||||||
|
siteURL: string;
|
||||||
|
cacheTTL: number;
|
||||||
|
debug: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfisicalSecret {
|
||||||
|
secretName: string;
|
||||||
|
secretValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfisicalServiceToken {
|
||||||
|
environment?: string;
|
||||||
|
scopes?: Array<{ environment: string; path: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InfisicalProvider implements SecretsProvider {
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
|
||||||
|
name: 'notice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Service Token',
|
||||||
|
name: 'token',
|
||||||
|
type: 'string',
|
||||||
|
hint: 'The Infisical Service Token with read access',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'e.g. st.64ae963e1874ea.374226a166439dce.39557e4a1b7bdd82',
|
||||||
|
noDataExpression: true,
|
||||||
|
typeOptions: { password: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Site URL',
|
||||||
|
name: 'siteURL',
|
||||||
|
type: 'string',
|
||||||
|
hint: "The absolute URL of the Infisical instance. Change it only if you're self-hosting Infisical.",
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: 'https://app.infisical.com',
|
||||||
|
default: 'https://app.infisical.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
displayName = 'Infisical';
|
||||||
|
|
||||||
|
name = 'infisical';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
private cachedSecrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
private client: InfisicalClient;
|
||||||
|
|
||||||
|
private settings: InfisicalSettings;
|
||||||
|
|
||||||
|
private environment: string;
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings): Promise<void> {
|
||||||
|
this.settings = settings.settings as unknown as InfisicalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Updated attempted on Infisical when initialization failed');
|
||||||
|
}
|
||||||
|
if (!(await this.test())[0]) {
|
||||||
|
throw new Error('Infisical provider test failed during update');
|
||||||
|
}
|
||||||
|
const secrets = (await this.client.getAllSecrets({
|
||||||
|
environment: this.environment,
|
||||||
|
path: '/',
|
||||||
|
attachToProcessEnv: false,
|
||||||
|
includeImports: true,
|
||||||
|
})) as InfisicalSecret[];
|
||||||
|
const newCache = Object.fromEntries(
|
||||||
|
secrets.map((s) => [s.secretName, s.secretValue]),
|
||||||
|
) as Record<string, string>;
|
||||||
|
if (Object.keys(newCache).length === 1 && '' in newCache) {
|
||||||
|
this.cachedSecrets = {};
|
||||||
|
} else {
|
||||||
|
this.cachedSecrets = newCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.client = new InfisicalClient(this.settings);
|
||||||
|
if ((await this.test())[0]) {
|
||||||
|
try {
|
||||||
|
this.environment = await this.getEnvironment();
|
||||||
|
this.state = 'connected';
|
||||||
|
} catch {
|
||||||
|
this.state = 'error';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.state = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnvironment(): Promise<string> {
|
||||||
|
const serviceTokenData = (await getServiceTokenData(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
this.client.clientConfig,
|
||||||
|
)) as InfisicalServiceToken;
|
||||||
|
if (serviceTokenData.environment) {
|
||||||
|
return serviceTokenData.environment;
|
||||||
|
}
|
||||||
|
if (serviceTokenData.scopes) {
|
||||||
|
return serviceTokenData.scopes[0].environment;
|
||||||
|
}
|
||||||
|
throw new Error("Couldn't find environment for Infisical");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
if (!this.client) {
|
||||||
|
return [false, 'Client not initialized'];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await populateClientWorkspaceConfigsHelper(this.client.clientConfig);
|
||||||
|
return [true];
|
||||||
|
} catch (e) {
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject {
|
||||||
|
return this.cachedSecrets[name] as unknown as IDataObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
return Object.keys(this.cachedSecrets).filter((k) => EXTERNAL_SECRETS_NAME_REGEX.test(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
return name in this.cachedSecrets;
|
||||||
|
}
|
||||||
|
}
|
559
packages/cli/src/ExternalSecrets/providers/vault.ts
Normal file
559
packages/cli/src/ExternalSecrets/providers/vault.ts
Normal file
|
@ -0,0 +1,559 @@
|
||||||
|
import type { SecretsProviderSettings, SecretsProviderState } from '@/Interfaces';
|
||||||
|
import { SecretsProvider } from '@/Interfaces';
|
||||||
|
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { getLogger } from '@/Logger';
|
||||||
|
import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
type VaultAuthMethod = 'token' | 'usernameAndPassword' | 'appRole';
|
||||||
|
|
||||||
|
interface VaultSettings {
|
||||||
|
url: string;
|
||||||
|
namespace?: string;
|
||||||
|
authMethod: VaultAuthMethod;
|
||||||
|
|
||||||
|
// Token
|
||||||
|
token: string;
|
||||||
|
renewToken: boolean;
|
||||||
|
|
||||||
|
// Username and Password
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
// AppRole
|
||||||
|
roleId: string;
|
||||||
|
secretId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultResponse<T> {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultTokenInfo {
|
||||||
|
accessor: string;
|
||||||
|
creation_time: number;
|
||||||
|
creation_ttl: number;
|
||||||
|
display_name: string;
|
||||||
|
entity_id: string;
|
||||||
|
expire_time: string | null;
|
||||||
|
explicit_max_ttl: number;
|
||||||
|
id: string;
|
||||||
|
issue_time: string;
|
||||||
|
meta: Record<string, string | number>;
|
||||||
|
num_uses: number;
|
||||||
|
orphan: boolean;
|
||||||
|
path: string;
|
||||||
|
policies: string[];
|
||||||
|
ttl: number;
|
||||||
|
renewable: boolean;
|
||||||
|
type: 'kv' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultMount {
|
||||||
|
accessor: string;
|
||||||
|
config: Record<string, string | number | boolean | null>;
|
||||||
|
description: string;
|
||||||
|
external_entropy_access: boolean;
|
||||||
|
local: boolean;
|
||||||
|
options: Record<string, string | number | boolean | null>;
|
||||||
|
plugin_version: string;
|
||||||
|
running_plugin_version: string;
|
||||||
|
running_sha256: string;
|
||||||
|
seal_wrap: number;
|
||||||
|
type: string;
|
||||||
|
uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultMountsResp {
|
||||||
|
[path: string]: VaultMount;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultUserPassLoginResp {
|
||||||
|
auth: {
|
||||||
|
client_token: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type VaultAppRoleResp = VaultUserPassLoginResp;
|
||||||
|
|
||||||
|
interface VaultSecretList {
|
||||||
|
keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VaultProvider extends SecretsProvider {
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
|
||||||
|
name: 'notice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Vault URL',
|
||||||
|
name: 'url',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: 'e.g. https://example.com/v1/',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Vault Namespace (optional)',
|
||||||
|
name: 'namespace',
|
||||||
|
type: 'string',
|
||||||
|
hint: 'Leave blank if not using namespaces',
|
||||||
|
required: false,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: 'e.g. admin',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authentication Method',
|
||||||
|
name: 'authMethod',
|
||||||
|
type: 'options',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
options: [
|
||||||
|
{ name: 'Token', value: 'token' },
|
||||||
|
{ name: 'Username and Password', value: 'usernameAndPassword' },
|
||||||
|
{ name: 'AppRole', value: 'appRole' },
|
||||||
|
],
|
||||||
|
default: 'token',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Token Auth
|
||||||
|
{
|
||||||
|
displayName: 'Token',
|
||||||
|
name: 'token',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: 'e.g. hvs.2OCsZxZA6Z9lChbt0janOOZI',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authMethod: ['token'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// displayName: 'Renew Token',
|
||||||
|
// name: 'renewToken',
|
||||||
|
// description:
|
||||||
|
// 'Try to renew Vault token. This will update the settings on this provider when doing so.',
|
||||||
|
// type: 'boolean',
|
||||||
|
// noDataExpression: true,
|
||||||
|
// default: true,
|
||||||
|
// displayOptions: {
|
||||||
|
// show: {
|
||||||
|
// authMethod: ['token'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
// Username and Password
|
||||||
|
{
|
||||||
|
displayName: 'Username',
|
||||||
|
name: 'username',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: 'Username',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authMethod: ['usernameAndPassword'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Password',
|
||||||
|
name: 'password',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: '***************',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authMethod: ['usernameAndPassword'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Username and Password
|
||||||
|
{
|
||||||
|
displayName: 'Role ID',
|
||||||
|
name: 'roleId',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: '59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authMethod: ['appRole'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Secret ID',
|
||||||
|
name: 'secretId',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
noDataExpression: true,
|
||||||
|
placeholder: '84896a0c-1347-aa90-a4f6-aca8b7558780',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authMethod: ['appRole'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
displayName = 'HashiCorp Vault';
|
||||||
|
|
||||||
|
name = 'vault';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
private cachedSecrets: Record<string, IDataObject> = {};
|
||||||
|
|
||||||
|
private settings: VaultSettings;
|
||||||
|
|
||||||
|
#currentToken: string | null = null;
|
||||||
|
|
||||||
|
#tokenInfo: VaultTokenInfo | null = null;
|
||||||
|
|
||||||
|
#http: AxiosInstance;
|
||||||
|
|
||||||
|
private refreshTimeout: NodeJS.Timer | null;
|
||||||
|
|
||||||
|
private refreshAbort = new AbortController();
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings): Promise<void> {
|
||||||
|
this.settings = settings.settings as unknown as VaultSettings;
|
||||||
|
|
||||||
|
const baseURL = new URL(this.settings.url);
|
||||||
|
|
||||||
|
this.#http = axios.create({ baseURL: baseURL.toString() });
|
||||||
|
if (this.settings.namespace) {
|
||||||
|
this.#http.interceptors.request.use((config) => {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment
|
||||||
|
headers: { ...config.headers, 'X-Vault-Namespace': this.settings.namespace },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.#http.interceptors.request.use((config) => {
|
||||||
|
if (!this.#currentToken) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment
|
||||||
|
return { ...config, headers: { ...config.headers, 'X-Vault-Token': this.#currentToken } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.settings.authMethod === 'token') {
|
||||||
|
this.#currentToken = this.settings.token;
|
||||||
|
} else if (this.settings.authMethod === 'usernameAndPassword') {
|
||||||
|
try {
|
||||||
|
this.#currentToken = await this.authUsernameAndPassword(
|
||||||
|
this.settings.username,
|
||||||
|
this.settings.password,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.state = 'error';
|
||||||
|
logger.error('Failed to connect to Vault using Username and Password credentials.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (this.settings.authMethod === 'appRole') {
|
||||||
|
try {
|
||||||
|
this.#currentToken = await this.authAppRole(this.settings.roleId, this.settings.secretId);
|
||||||
|
} catch {
|
||||||
|
this.state = 'error';
|
||||||
|
logger.error('Failed to connect to Vault using AppRole credentials.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!(await this.test())[0]) {
|
||||||
|
this.state = 'error';
|
||||||
|
} else {
|
||||||
|
this.state = 'connected';
|
||||||
|
|
||||||
|
[this.#tokenInfo] = await this.getTokenInfo();
|
||||||
|
this.setupTokenRefresh();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.state = 'error';
|
||||||
|
logger.error('Failed credentials test on Vault connect.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.update();
|
||||||
|
} catch {
|
||||||
|
logger.warn('Failed to update Vault secrets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.refreshTimeout !== null) {
|
||||||
|
clearTimeout(this.refreshTimeout);
|
||||||
|
}
|
||||||
|
this.refreshAbort.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTokenRefresh() {
|
||||||
|
if (!this.#tokenInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Token never expires
|
||||||
|
if (this.#tokenInfo.expire_time === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Token can't be renewed
|
||||||
|
if (!this.#tokenInfo.renewable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireDate = new Date(this.#tokenInfo.expire_time);
|
||||||
|
setTimeout(this.tokenRefresh, (expireDate.valueOf() - Date.now()) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tokenRefresh = async () => {
|
||||||
|
if (this.refreshAbort.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// We don't actually care about the result of this since it doesn't
|
||||||
|
// return an expire_time
|
||||||
|
await this.#http.post('auth/token/renew-self');
|
||||||
|
|
||||||
|
[this.#tokenInfo] = await this.getTokenInfo();
|
||||||
|
|
||||||
|
if (!this.#tokenInfo) {
|
||||||
|
logger.error('Failed to fetch token info during renewal. Cancelling all future renewals.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.refreshAbort.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupTokenRefresh();
|
||||||
|
} catch {
|
||||||
|
logger.error('Failed to renew Vault token. Attempting to reconnect.');
|
||||||
|
void this.connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private async authUsernameAndPassword(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await this.#http.request<VaultUserPassLoginResp>({
|
||||||
|
method: 'POST',
|
||||||
|
url: `auth/userpass/login/${username}`,
|
||||||
|
responseType: 'json',
|
||||||
|
data: { password },
|
||||||
|
});
|
||||||
|
|
||||||
|
return resp.data.auth.client_token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authAppRole(roleId: string, secretId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await this.#http.request<VaultAppRoleResp>({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'auth/approle/login',
|
||||||
|
responseType: 'json',
|
||||||
|
data: { role_id: roleId, secret_id: secretId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return resp.data.auth.client_token;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTokenInfo(): Promise<[VaultTokenInfo | null, AxiosResponse]> {
|
||||||
|
const resp = await this.#http.request<VaultResponse<VaultTokenInfo>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'auth/token/lookup-self',
|
||||||
|
responseType: 'json',
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status !== 200 || !resp.data.data) {
|
||||||
|
return [null, resp];
|
||||||
|
}
|
||||||
|
return [resp.data.data, resp];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getKVSecrets(
|
||||||
|
mountPath: string,
|
||||||
|
kvVersion: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<[string, IDataObject] | null> {
|
||||||
|
let listPath = mountPath;
|
||||||
|
if (kvVersion === '2') {
|
||||||
|
listPath += 'metadata/';
|
||||||
|
}
|
||||||
|
listPath += path;
|
||||||
|
let listResp: AxiosResponse<VaultResponse<VaultSecretList>>;
|
||||||
|
try {
|
||||||
|
listResp = await this.#http.request<VaultResponse<VaultSecretList>>({
|
||||||
|
url: listPath,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
|
||||||
|
method: 'LIST' as any,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = Object.fromEntries(
|
||||||
|
(
|
||||||
|
await Promise.allSettled(
|
||||||
|
listResp.data.data.keys.map(async (key): Promise<[string, IDataObject] | null> => {
|
||||||
|
if (key.endsWith('/')) {
|
||||||
|
return this.getKVSecrets(mountPath, kvVersion, path + key);
|
||||||
|
}
|
||||||
|
let secretPath = mountPath;
|
||||||
|
if (kvVersion === '2') {
|
||||||
|
secretPath += 'data/';
|
||||||
|
}
|
||||||
|
secretPath += path + key;
|
||||||
|
try {
|
||||||
|
const secretResp = await this.#http.get<VaultResponse<IDataObject>>(secretPath);
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
kvVersion === '2'
|
||||||
|
? (secretResp.data.data.data as IDataObject)
|
||||||
|
: secretResp.data.data,
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((i) => (i.status === 'rejected' ? null : i.value))
|
||||||
|
.filter((v) => v !== null) as Array<[string, IDataObject]>,
|
||||||
|
);
|
||||||
|
const name = path.substring(0, path.length - 1);
|
||||||
|
return [name, data];
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
const mounts = await this.#http.get<VaultResponse<VaultMountsResp>>('sys/mounts');
|
||||||
|
|
||||||
|
const kvs = Object.entries(mounts.data.data).filter(([, v]) => v.type === 'kv');
|
||||||
|
|
||||||
|
const secrets = Object.fromEntries(
|
||||||
|
(
|
||||||
|
await Promise.all(
|
||||||
|
kvs.map(async ([basePath, data]): Promise<[string, IDataObject] | null> => {
|
||||||
|
const value = await this.getKVSecrets(basePath, data.options.version as string, '');
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [basePath.substring(0, basePath.length - 1), value[1]];
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((v) => v !== null) as Array<[string, IDataObject]>,
|
||||||
|
);
|
||||||
|
this.cachedSecrets = secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
try {
|
||||||
|
const [token, tokenResp] = await this.getTokenInfo();
|
||||||
|
|
||||||
|
if (token === null) {
|
||||||
|
if (tokenResp.status === 404) {
|
||||||
|
return [false, 'Could not find auth path. Try adding /v1/ to the end of your base URL.'];
|
||||||
|
}
|
||||||
|
return [false, 'Invalid credentials'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await this.#http.request<VaultResponse<VaultTokenInfo>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'sys/mounts',
|
||||||
|
responseType: 'json',
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
"Couldn't list mounts. Please give these credentials 'read' access to sys/mounts.",
|
||||||
|
];
|
||||||
|
} else if (resp.status !== 200) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
"Couldn't list mounts but wasn't a permissions issue. Please consult your Vault admin.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true];
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
if (e.code === 'ECONNREFUSED') {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
'Connection refused. Please check the host and port of the server are correct.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject {
|
||||||
|
return this.cachedSecrets[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
return name in this.cachedSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
const getKeys = ([k, v]: [string, IDataObject]): string[] => {
|
||||||
|
if (!EXTERNAL_SECRETS_NAME_REGEX.test(k)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const key of Object.keys(v)) {
|
||||||
|
if (!EXTERNAL_SECRETS_NAME_REGEX.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = v[key];
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
keys.push(...getKeys([key, value as IDataObject]).map((ok) => `${k}.${ok}`));
|
||||||
|
} else {
|
||||||
|
keys.push(`${k}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
return [k];
|
||||||
|
};
|
||||||
|
return Object.entries(this.cachedSecrets).flatMap(getKeys);
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ import type {
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
IExecutionsSummary,
|
IExecutionsSummary,
|
||||||
FeatureFlags,
|
FeatureFlags,
|
||||||
|
INodeProperties,
|
||||||
IUserSettings,
|
IUserSettings,
|
||||||
IHttpRequestMethods,
|
IHttpRequestMethods,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
@ -460,6 +461,13 @@ export interface IInternalHooksClass {
|
||||||
onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
||||||
onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
||||||
onVariableCreated(createData: { variable_type: string }): Promise<void>;
|
onVariableCreated(createData: { variable_type: string }): Promise<void>;
|
||||||
|
onExternalSecretsProviderSettingsSaved(saveData: {
|
||||||
|
user_id?: string;
|
||||||
|
vault_type: string;
|
||||||
|
is_valid: boolean;
|
||||||
|
is_new: boolean;
|
||||||
|
error_message?: string;
|
||||||
|
}): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
|
@ -779,4 +787,35 @@ export interface N8nApp {
|
||||||
|
|
||||||
export type UserSettings = Pick<User, 'id' | 'settings'>;
|
export type UserSettings = Pick<User, 'id' | 'settings'>;
|
||||||
|
|
||||||
|
export interface SecretsProviderSettings<T = IDataObject> {
|
||||||
|
connected: boolean;
|
||||||
|
connectedAt: Date | null;
|
||||||
|
settings: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalSecretsSettings {
|
||||||
|
[key: string]: SecretsProviderSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SecretsProviderState = 'initializing' | 'connected' | 'error';
|
||||||
|
|
||||||
|
export abstract class SecretsProvider {
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
properties: INodeProperties[];
|
||||||
|
|
||||||
|
state: SecretsProviderState;
|
||||||
|
|
||||||
|
abstract init(settings: SecretsProviderSettings): Promise<void>;
|
||||||
|
abstract connect(): Promise<void>;
|
||||||
|
abstract disconnect(): Promise<void>;
|
||||||
|
abstract update(): Promise<void>;
|
||||||
|
abstract test(): Promise<[boolean] | [boolean, string]>;
|
||||||
|
abstract getSecret(name: string): IDataObject | undefined;
|
||||||
|
abstract hasSecret(name: string): boolean;
|
||||||
|
abstract getSecretNames(): string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type N8nInstanceType = 'main' | 'webhook' | 'worker';
|
export type N8nInstanceType = 'main' | 'webhook' | 'worker';
|
||||||
|
|
|
@ -1086,4 +1086,14 @@ export class InternalHooks implements IInternalHooksClass {
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.telemetry.track('User finished push via UI', data);
|
return this.telemetry.track('User finished push via UI', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onExternalSecretsProviderSettingsSaved(saveData: {
|
||||||
|
user_id?: string | undefined;
|
||||||
|
vault_type: string;
|
||||||
|
is_valid: boolean;
|
||||||
|
is_new: boolean;
|
||||||
|
error_message?: string | undefined;
|
||||||
|
}): Promise<void> {
|
||||||
|
return this.telemetry.track('User updated external secrets settings', saveData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,6 +140,10 @@ export class License {
|
||||||
return this.isFeatureEnabled(LICENSE_FEATURES.SOURCE_CONTROL);
|
return this.isFeatureEnabled(LICENSE_FEATURES.SOURCE_CONTROL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isExternalSecretsEnabled() {
|
||||||
|
return this.isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_SECRETS);
|
||||||
|
}
|
||||||
|
|
||||||
isWorkflowHistoryLicensed() {
|
isWorkflowHistoryLicensed() {
|
||||||
return this.isFeatureEnabled(LICENSE_FEATURES.WORKFLOW_HISTORY);
|
return this.isFeatureEnabled(LICENSE_FEATURES.WORKFLOW_HISTORY);
|
||||||
}
|
}
|
||||||
|
|
41
packages/cli/src/SecretsHelpers.ts
Normal file
41
packages/cli/src/SecretsHelpers.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import type { IDataObject, SecretsHelpersBase } from 'n8n-workflow';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { ExternalSecretsManager } from './ExternalSecrets/ExternalSecretsManager.ee';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SecretsHelper implements SecretsHelpersBase {
|
||||||
|
constructor(private service: ExternalSecretsManager) {}
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
if (!this.service.initialized) {
|
||||||
|
await this.service.init();
|
||||||
|
}
|
||||||
|
await this.service.updateSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForInit() {
|
||||||
|
if (!this.service.initialized) {
|
||||||
|
await this.service.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(provider: string, name: string): IDataObject | undefined {
|
||||||
|
return this.service.getSecret(provider, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(provider: string, name: string): boolean {
|
||||||
|
return this.service.hasSecret(provider, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProvider(provider: string): boolean {
|
||||||
|
return this.service.hasProvider(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
listProviders(): string[] {
|
||||||
|
return this.service.getProviderNames() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
listSecrets(provider: string): string[] {
|
||||||
|
return this.service.getSecretNames(provider) ?? [];
|
||||||
|
}
|
||||||
|
}
|
|
@ -99,6 +99,7 @@ import {
|
||||||
WorkflowStatisticsController,
|
WorkflowStatisticsController,
|
||||||
} from '@/controllers';
|
} from '@/controllers';
|
||||||
|
|
||||||
|
import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee';
|
||||||
import { executionsController } from '@/executions/executions.controller';
|
import { executionsController } from '@/executions/executions.controller';
|
||||||
import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
|
import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
|
||||||
import {
|
import {
|
||||||
|
@ -163,6 +164,7 @@ import {
|
||||||
isLdapCurrentAuthenticationMethod,
|
isLdapCurrentAuthenticationMethod,
|
||||||
isSamlCurrentAuthenticationMethod,
|
isSamlCurrentAuthenticationMethod,
|
||||||
} from './sso/ssoHelpers';
|
} from './sso/ssoHelpers';
|
||||||
|
import { isExternalSecretsEnabled } from './ExternalSecrets/externalSecretsHelper.ee';
|
||||||
import { isSourceControlLicensed } from '@/environments/sourceControl/sourceControlHelper.ee';
|
import { isSourceControlLicensed } from '@/environments/sourceControl/sourceControlHelper.ee';
|
||||||
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
|
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
|
||||||
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
|
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
|
||||||
|
@ -314,6 +316,7 @@ export class Server extends AbstractServer {
|
||||||
variables: false,
|
variables: false,
|
||||||
sourceControl: false,
|
sourceControl: false,
|
||||||
auditLogs: false,
|
auditLogs: false,
|
||||||
|
externalSecrets: false,
|
||||||
showNonProdBanner: false,
|
showNonProdBanner: false,
|
||||||
debugInEditor: false,
|
debugInEditor: false,
|
||||||
},
|
},
|
||||||
|
@ -451,6 +454,7 @@ export class Server extends AbstractServer {
|
||||||
advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(),
|
advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(),
|
||||||
variables: isVariablesEnabled(),
|
variables: isVariablesEnabled(),
|
||||||
sourceControl: isSourceControlLicensed(),
|
sourceControl: isSourceControlLicensed(),
|
||||||
|
externalSecrets: isExternalSecretsEnabled(),
|
||||||
showNonProdBanner: Container.get(License).isFeatureEnabled(
|
showNonProdBanner: Container.get(License).isFeatureEnabled(
|
||||||
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
||||||
),
|
),
|
||||||
|
@ -526,6 +530,7 @@ export class Server extends AbstractServer {
|
||||||
Container.get(SamlController),
|
Container.get(SamlController),
|
||||||
Container.get(SourceControlController),
|
Container.get(SourceControlController),
|
||||||
Container.get(WorkflowStatisticsController),
|
Container.get(WorkflowStatisticsController),
|
||||||
|
Container.get(ExternalSecretsController),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLdapEnabled()) {
|
if (isLdapEnabled()) {
|
||||||
|
@ -929,10 +934,13 @@ export class Server extends AbstractServer {
|
||||||
throw new ResponseHelper.InternalServerError(error.message);
|
throw new ResponseHelper.InternalServerError(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
|
||||||
|
|
||||||
const mode: WorkflowExecuteMode = 'internal';
|
const mode: WorkflowExecuteMode = 'internal';
|
||||||
const timezone = config.getEnv('generic.timezone');
|
const timezone = config.getEnv('generic.timezone');
|
||||||
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
||||||
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
||||||
|
additionalData,
|
||||||
credential as INodeCredentialsDetails,
|
credential as INodeCredentialsDetails,
|
||||||
credential.type,
|
credential.type,
|
||||||
mode,
|
mode,
|
||||||
|
@ -941,6 +949,7 @@ export class Server extends AbstractServer {
|
||||||
);
|
);
|
||||||
|
|
||||||
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
||||||
|
additionalData,
|
||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
credential.type,
|
credential.type,
|
||||||
mode,
|
mode,
|
||||||
|
@ -1075,10 +1084,13 @@ export class Server extends AbstractServer {
|
||||||
throw new ResponseHelper.InternalServerError(error.message);
|
throw new ResponseHelper.InternalServerError(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
|
||||||
|
|
||||||
const mode: WorkflowExecuteMode = 'internal';
|
const mode: WorkflowExecuteMode = 'internal';
|
||||||
const timezone = config.getEnv('generic.timezone');
|
const timezone = config.getEnv('generic.timezone');
|
||||||
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
||||||
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
||||||
|
additionalData,
|
||||||
credential as INodeCredentialsDetails,
|
credential as INodeCredentialsDetails,
|
||||||
credential.type,
|
credential.type,
|
||||||
mode,
|
mode,
|
||||||
|
@ -1086,6 +1098,7 @@ export class Server extends AbstractServer {
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
||||||
|
additionalData,
|
||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
credential.type,
|
credential.type,
|
||||||
mode,
|
mode,
|
||||||
|
|
|
@ -65,6 +65,7 @@ import { InternalHooks } from '@/InternalHooks';
|
||||||
import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata';
|
import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata';
|
||||||
import { ExecutionRepository } from '@db/repositories';
|
import { ExecutionRepository } from '@db/repositories';
|
||||||
import { EventsService } from '@/services/events.service';
|
import { EventsService } from '@/services/events.service';
|
||||||
|
import { SecretsHelper } from './SecretsHelpers';
|
||||||
import { OwnershipService } from './services/ownership.service';
|
import { OwnershipService } from './services/ownership.service';
|
||||||
|
|
||||||
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
|
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
|
||||||
|
@ -1167,6 +1168,7 @@ export async function getBase(
|
||||||
userId,
|
userId,
|
||||||
setExecutionStatus,
|
setExecutionStatus,
|
||||||
variables,
|
variables,
|
||||||
|
secretsHelpers: Container.get(SecretsHelper),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ import type { IExternalHooksClass } from '@/Interfaces';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { PostHogClient } from '@/posthog';
|
import { PostHogClient } from '@/posthog';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee';
|
||||||
|
|
||||||
export abstract class BaseCommand extends Command {
|
export abstract class BaseCommand extends Command {
|
||||||
protected logger = LoggerProxy.init(getLogger());
|
protected logger = LoggerProxy.init(getLogger());
|
||||||
|
@ -134,6 +135,11 @@ export abstract class BaseCommand extends Command {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initExternalSecrets() {
|
||||||
|
const secretsManager = Container.get(ExternalSecretsManager);
|
||||||
|
await secretsManager.init();
|
||||||
|
}
|
||||||
|
|
||||||
async finally(error: Error | undefined) {
|
async finally(error: Error | undefined) {
|
||||||
if (inTest || this.id === 'start') return;
|
if (inTest || this.id === 'start') return;
|
||||||
if (Db.connectionState.connected) {
|
if (Db.connectionState.connected) {
|
||||||
|
|
|
@ -195,6 +195,7 @@ export class Start extends BaseCommand {
|
||||||
await this.initLicense();
|
await this.initLicense();
|
||||||
await this.initBinaryManager();
|
await this.initBinaryManager();
|
||||||
await this.initExternalHooks();
|
await this.initExternalHooks();
|
||||||
|
await this.initExternalSecrets();
|
||||||
|
|
||||||
if (!config.getEnv('endpoints.disableUi')) {
|
if (!config.getEnv('endpoints.disableUi')) {
|
||||||
await this.generateStaticAssets();
|
await this.generateStaticAssets();
|
||||||
|
|
|
@ -80,6 +80,7 @@ export class Webhook extends BaseCommand {
|
||||||
await this.initLicense();
|
await this.initLicense();
|
||||||
await this.initBinaryManager();
|
await this.initBinaryManager();
|
||||||
await this.initExternalHooks();
|
await this.initExternalHooks();
|
||||||
|
await this.initExternalSecrets();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
|
|
|
@ -239,6 +239,7 @@ export class Worker extends BaseCommand {
|
||||||
await this.initLicense();
|
await this.initLicense();
|
||||||
await this.initBinaryManager();
|
await this.initBinaryManager();
|
||||||
await this.initExternalHooks();
|
await this.initExternalHooks();
|
||||||
|
await this.initExternalSecrets();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
|
|
|
@ -77,6 +77,7 @@ export const LICENSE_FEATURES = {
|
||||||
VARIABLES: 'feat:variables',
|
VARIABLES: 'feat:variables',
|
||||||
SOURCE_CONTROL: 'feat:sourceControl',
|
SOURCE_CONTROL: 'feat:sourceControl',
|
||||||
API_DISABLED: 'feat:apiDisabled',
|
API_DISABLED: 'feat:apiDisabled',
|
||||||
|
EXTERNAL_SECRETS: 'feat:externalSecrets',
|
||||||
SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner',
|
SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner',
|
||||||
WORKFLOW_HISTORY: 'feat:workflowHistory',
|
WORKFLOW_HISTORY: 'feat:workflowHistory',
|
||||||
DEBUG_IN_EDITOR: 'feat:debugInEditor',
|
DEBUG_IN_EDITOR: 'feat:debugInEditor',
|
||||||
|
|
|
@ -64,6 +64,7 @@ export class E2EController {
|
||||||
[LICENSE_FEATURES.SOURCE_CONTROL]: false,
|
[LICENSE_FEATURES.SOURCE_CONTROL]: false,
|
||||||
[LICENSE_FEATURES.VARIABLES]: false,
|
[LICENSE_FEATURES.VARIABLES]: false,
|
||||||
[LICENSE_FEATURES.API_DISABLED]: false,
|
[LICENSE_FEATURES.API_DISABLED]: false,
|
||||||
|
[LICENSE_FEATURES.EXTERNAL_SECRETS]: false,
|
||||||
[LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false,
|
[LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false,
|
||||||
[LICENSE_FEATURES.WORKFLOW_HISTORY]: false,
|
[LICENSE_FEATURES.WORKFLOW_HISTORY]: false,
|
||||||
[LICENSE_FEATURES.DEBUG_IN_EDITOR]: false,
|
[LICENSE_FEATURES.DEBUG_IN_EDITOR]: false,
|
||||||
|
|
|
@ -309,7 +309,10 @@ export class CredentialsService {
|
||||||
if (!prop) {
|
if (!prop) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prop.typeOptions?.password) {
|
if (
|
||||||
|
prop.typeOptions?.password &&
|
||||||
|
(!(copiedData[dataKey] as string).startsWith('={{') || prop.noDataExpression)
|
||||||
|
) {
|
||||||
if (copiedData[dataKey].toString().length > 0) {
|
if (copiedData[dataKey].toString().length > 0) {
|
||||||
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
|
copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -34,6 +34,8 @@ import config from '@/config';
|
||||||
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
|
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
||||||
|
|
||||||
export const oauth2CredentialController = express.Router();
|
export const oauth2CredentialController = express.Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -81,12 +83,15 @@ oauth2CredentialController.get(
|
||||||
throw new ResponseHelper.InternalServerError((error as Error).message);
|
throw new ResponseHelper.InternalServerError((error as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
|
||||||
|
|
||||||
const credentialType = (credential as unknown as ICredentialsEncrypted).type;
|
const credentialType = (credential as unknown as ICredentialsEncrypted).type;
|
||||||
|
|
||||||
const mode: WorkflowExecuteMode = 'internal';
|
const mode: WorkflowExecuteMode = 'internal';
|
||||||
const timezone = config.getEnv('generic.timezone');
|
const timezone = config.getEnv('generic.timezone');
|
||||||
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
||||||
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
||||||
|
additionalData,
|
||||||
credential as INodeCredentialsDetails,
|
credential as INodeCredentialsDetails,
|
||||||
credentialType,
|
credentialType,
|
||||||
mode,
|
mode,
|
||||||
|
@ -107,6 +112,7 @@ oauth2CredentialController.get(
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
||||||
|
additionalData,
|
||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
credentialType,
|
credentialType,
|
||||||
mode,
|
mode,
|
||||||
|
@ -223,11 +229,13 @@ oauth2CredentialController.get(
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(state.cid);
|
||||||
|
|
||||||
const mode: WorkflowExecuteMode = 'internal';
|
const mode: WorkflowExecuteMode = 'internal';
|
||||||
const timezone = config.getEnv('generic.timezone');
|
const timezone = config.getEnv('generic.timezone');
|
||||||
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
const credentialsHelper = new CredentialsHelper(encryptionKey);
|
||||||
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
||||||
|
additionalData,
|
||||||
credential as INodeCredentialsDetails,
|
credential as INodeCredentialsDetails,
|
||||||
(credential as unknown as ICredentialsEncrypted).type,
|
(credential as unknown as ICredentialsEncrypted).type,
|
||||||
mode,
|
mode,
|
||||||
|
@ -235,6 +243,7 @@ oauth2CredentialController.get(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
||||||
|
additionalData,
|
||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
(credential as unknown as ICredentialsEncrypted).type,
|
(credential as unknown as ICredentialsEncrypted).type,
|
||||||
mode,
|
mode,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { EXTERNAL_SECRETS_DB_KEY } from '@/ExternalSecrets/constants';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
||||||
|
@ -10,6 +11,21 @@ export class SettingsRepository extends Repository<Settings> {
|
||||||
super(Settings, dataSource.manager);
|
super(Settings, dataSource.manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEncryptedSecretsProviderSettings(): Promise<string | null> {
|
||||||
|
return (await this.findOne({ where: { key: EXTERNAL_SECRETS_DB_KEY } }))?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveEncryptedSecretsProviderSettings(data: string): Promise<void> {
|
||||||
|
await this.upsert(
|
||||||
|
{
|
||||||
|
key: EXTERNAL_SECRETS_DB_KEY,
|
||||||
|
value: data,
|
||||||
|
loadOnStartup: false,
|
||||||
|
},
|
||||||
|
['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async dismissBanner({ bannerName }: { bannerName: string }): Promise<{ success: boolean }> {
|
async dismissBanner({ bannerName }: { bannerName: string }): Promise<{ success: boolean }> {
|
||||||
const key = 'ui.banners.dismissed';
|
const key = 'ui.banners.dismissed';
|
||||||
const dismissedBannersSetting = await this.findOneBy({ key });
|
const dismissedBannersSetting = await this.findOneBy({ key });
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
MessageEventBusDestinationOptions,
|
MessageEventBusDestinationOptions,
|
||||||
MessageEventBusDestinationWebhookParameterItem,
|
MessageEventBusDestinationWebhookParameterItem,
|
||||||
MessageEventBusDestinationWebhookParameterOptions,
|
MessageEventBusDestinationWebhookParameterOptions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||||
import { UserSettings } from 'n8n-core';
|
import { UserSettings } from 'n8n-core';
|
||||||
|
@ -24,6 +25,7 @@ import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'
|
||||||
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';
|
import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric';
|
||||||
import { MessageEventBus } from '../MessageEventBus/MessageEventBus';
|
import { MessageEventBus } from '../MessageEventBus/MessageEventBus';
|
||||||
import type { MessageWithCallback } from '../MessageEventBus/MessageEventBus';
|
import type { MessageWithCallback } from '../MessageEventBus/MessageEventBus';
|
||||||
|
import * as SecretsHelpers from '@/ExternalSecrets/externalSecretsHelper.ee';
|
||||||
|
|
||||||
export const isMessageEventBusDestinationWebhookOptions = (
|
export const isMessageEventBusDestinationWebhookOptions = (
|
||||||
candidate: unknown,
|
candidate: unknown,
|
||||||
|
@ -108,6 +110,7 @@ export class MessageEventBusDestinationWebhook
|
||||||
if (foundCredential) {
|
if (foundCredential) {
|
||||||
const timezone = config.getEnv('generic.timezone');
|
const timezone = config.getEnv('generic.timezone');
|
||||||
const credentialsDecrypted = await this.credentialsHelper?.getDecrypted(
|
const credentialsDecrypted = await this.credentialsHelper?.getDecrypted(
|
||||||
|
{ secretsHelpers: SecretsHelpers } as unknown as IWorkflowExecuteAdditionalData,
|
||||||
foundCredential[1],
|
foundCredential[1],
|
||||||
foundCredential[0],
|
foundCredential[0],
|
||||||
'internal',
|
'internal',
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
IConnections,
|
IConnections,
|
||||||
ICredentialDataDecryptedObject,
|
ICredentialDataDecryptedObject,
|
||||||
ICredentialNodeAccess,
|
ICredentialNodeAccess,
|
||||||
|
IDataObject,
|
||||||
INode,
|
INode,
|
||||||
INodeCredentialTestRequest,
|
INodeCredentialTestRequest,
|
||||||
IPinData,
|
IPinData,
|
||||||
|
@ -14,7 +15,13 @@ import type {
|
||||||
|
|
||||||
import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator';
|
import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator';
|
||||||
import { NoXss } from '@db/utils/customValidators';
|
import { NoXss } from '@db/utils/customValidators';
|
||||||
import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
|
import type {
|
||||||
|
PublicUser,
|
||||||
|
IExecutionDeleteFilter,
|
||||||
|
IWorkflowDb,
|
||||||
|
SecretsProvider,
|
||||||
|
SecretsProviderState,
|
||||||
|
} from '@/Interfaces';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import type { UserManagementMailer } from '@/UserManagement/email';
|
import type { UserManagementMailer } from '@/UserManagement/email';
|
||||||
|
@ -497,3 +504,25 @@ export declare namespace VariablesRequest {
|
||||||
type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>;
|
type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>;
|
||||||
type Delete = Get;
|
type Delete = Get;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export declare namespace ExternalSecretsRequest {
|
||||||
|
type GetProviderResponse = Pick<SecretsProvider, 'displayName' | 'name' | 'properties'> & {
|
||||||
|
icon: string;
|
||||||
|
connected: boolean;
|
||||||
|
connectedAt: Date | null;
|
||||||
|
state: SecretsProviderState;
|
||||||
|
data: IDataObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetProviders = AuthenticatedRequest;
|
||||||
|
type GetProvider = AuthenticatedRequest<{ provider: string }, GetProviderResponse>;
|
||||||
|
type SetProviderSettings = AuthenticatedRequest<{ provider: string }, {}, IDataObject>;
|
||||||
|
type TestProviderSettings = SetProviderSettings;
|
||||||
|
type SetProviderConnected = AuthenticatedRequest<
|
||||||
|
{ provider: string },
|
||||||
|
{},
|
||||||
|
{ connected: boolean }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type UpdateProvider = AuthenticatedRequest<{ provider: string }>;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,370 @@
|
||||||
|
import type { SuperAgentTest } from 'supertest';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import * as testDb from '../shared/testDb';
|
||||||
|
import * as utils from '../shared/utils/';
|
||||||
|
import type { ExternalSecretsSettings, SecretsProviderState } from '@/Interfaces';
|
||||||
|
import { UserSettings } from 'n8n-core';
|
||||||
|
import { SettingsRepository } from '@/databases/repositories/settings.repository';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import { AES, enc } from 'crypto-js';
|
||||||
|
import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee';
|
||||||
|
import {
|
||||||
|
DummyProvider,
|
||||||
|
FailedProvider,
|
||||||
|
MockProviders,
|
||||||
|
TestFailProvider,
|
||||||
|
} from '../../shared/ExternalSecrets/utils';
|
||||||
|
import config from '@/config';
|
||||||
|
import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee';
|
||||||
|
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
|
let authOwnerAgent: SuperAgentTest;
|
||||||
|
let authMemberAgent: SuperAgentTest;
|
||||||
|
|
||||||
|
const licenseLike = utils.mockInstance(License, {
|
||||||
|
isExternalSecretsEnabled: jest.fn().mockReturnValue(true),
|
||||||
|
isWithinUsersLimit: jest.fn().mockReturnValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProvidersInstance = new MockProviders();
|
||||||
|
let providersMock: ExternalSecretsProviders = utils.mockInstance(
|
||||||
|
ExternalSecretsProviders,
|
||||||
|
mockProvidersInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const testServer = utils.setupTestServer({ endpointGroups: ['externalSecrets'] });
|
||||||
|
|
||||||
|
const connectedDate = '2023-08-01T12:32:29.000Z';
|
||||||
|
|
||||||
|
async function setExternalSecretsSettings(settings: ExternalSecretsSettings) {
|
||||||
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
|
return Container.get(SettingsRepository).saveEncryptedSecretsProviderSettings(
|
||||||
|
AES.encrypt(JSON.stringify(settings), encryptionKey).toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExternalSecretsSettings(): Promise<ExternalSecretsSettings | null> {
|
||||||
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
|
const encSettings = await Container.get(SettingsRepository).getEncryptedSecretsProviderSettings();
|
||||||
|
if (encSettings === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(AES.decrypt(encSettings, encryptionKey).toString(enc.Utf8));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetManager = async () => {
|
||||||
|
Container.get(ExternalSecretsManager).shutdown();
|
||||||
|
Container.set(
|
||||||
|
ExternalSecretsManager,
|
||||||
|
new ExternalSecretsManager(
|
||||||
|
Container.get(SettingsRepository),
|
||||||
|
licenseLike,
|
||||||
|
mockProvidersInstance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Container.get(ExternalSecretsManager).init();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDummyProviderData = ({
|
||||||
|
data,
|
||||||
|
includeProperties,
|
||||||
|
connected,
|
||||||
|
state,
|
||||||
|
connectedAt,
|
||||||
|
displayName,
|
||||||
|
}: {
|
||||||
|
data?: IDataObject;
|
||||||
|
includeProperties?: boolean;
|
||||||
|
connected?: boolean;
|
||||||
|
state?: SecretsProviderState;
|
||||||
|
connectedAt?: string | null;
|
||||||
|
displayName?: string;
|
||||||
|
} = {}) => {
|
||||||
|
const dummy: IDataObject = {
|
||||||
|
connected: connected ?? true,
|
||||||
|
connectedAt: connectedAt === undefined ? connectedDate : connectedAt,
|
||||||
|
data: data ?? {},
|
||||||
|
name: 'dummy',
|
||||||
|
displayName: displayName ?? 'Dummy Provider',
|
||||||
|
icon: 'dummy',
|
||||||
|
state: state ?? 'connected',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeProperties) {
|
||||||
|
dummy.properties = new DummyProvider().properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dummy;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.initEncryptionKey();
|
||||||
|
|
||||||
|
const owner = await testDb.createOwner();
|
||||||
|
authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
|
const member = await testDb.createUser();
|
||||||
|
authMemberAgent = testServer.authAgentFor(member);
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
licenseLike.isExternalSecretsEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: DummyProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setExternalSecretsSettings({
|
||||||
|
dummy: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(connectedDate),
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
Container.get(ExternalSecretsManager).shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /external-secrets/providers', () => {
|
||||||
|
test('can retrieve providers as owner', async () => {
|
||||||
|
const resp = await authOwnerAgent.get('/external-secrets/providers');
|
||||||
|
expect(resp.body).toEqual({
|
||||||
|
data: [getDummyProviderData()],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can not retrieve providers as non-owner', async () => {
|
||||||
|
const resp = await authMemberAgent.get('/external-secrets/providers');
|
||||||
|
expect(resp.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does obscure passwords', async () => {
|
||||||
|
await setExternalSecretsSettings({
|
||||||
|
dummy: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(connectedDate),
|
||||||
|
settings: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get('/external-secrets/providers');
|
||||||
|
expect(resp.body).toEqual({
|
||||||
|
data: [
|
||||||
|
getDummyProviderData({
|
||||||
|
data: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: CREDENTIAL_BLANKING_VALUE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /external-secrets/providers/:provider', () => {
|
||||||
|
test('can retrieve provider as owner', async () => {
|
||||||
|
const resp = await authOwnerAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect(resp.body.data).toEqual(getDummyProviderData({ includeProperties: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can not retrieve provider as non-owner', async () => {
|
||||||
|
const resp = await authMemberAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect(resp.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does obscure passwords', async () => {
|
||||||
|
await setExternalSecretsSettings({
|
||||||
|
dummy: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(connectedDate),
|
||||||
|
settings: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect(resp.body.data).toEqual(
|
||||||
|
getDummyProviderData({
|
||||||
|
data: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: CREDENTIAL_BLANKING_VALUE,
|
||||||
|
},
|
||||||
|
includeProperties: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /external-secrets/providers/:provider', () => {
|
||||||
|
test('can update provider settings', async () => {
|
||||||
|
const testData = {
|
||||||
|
username: 'testuser',
|
||||||
|
other: 'testother',
|
||||||
|
};
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
|
||||||
|
const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect(confirmResp.body.data).toEqual(
|
||||||
|
getDummyProviderData({ data: testData, includeProperties: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can update provider settings with blanking value', async () => {
|
||||||
|
await setExternalSecretsSettings({
|
||||||
|
dummy: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: new Date(connectedDate),
|
||||||
|
settings: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
username: 'newuser',
|
||||||
|
password: CREDENTIAL_BLANKING_VALUE,
|
||||||
|
};
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
|
||||||
|
const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect((await getExternalSecretsSettings())?.dummy.settings).toEqual({
|
||||||
|
username: 'newuser',
|
||||||
|
password: 'testpass',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /external-secrets/providers/:provider/connect', () => {
|
||||||
|
test('can change provider connected state', async () => {
|
||||||
|
const testData = {
|
||||||
|
connected: false,
|
||||||
|
};
|
||||||
|
const resp = await authOwnerAgent
|
||||||
|
.post('/external-secrets/providers/dummy/connect')
|
||||||
|
.send(testData);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
|
||||||
|
const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy');
|
||||||
|
expect(confirmResp.body.data).toEqual(
|
||||||
|
getDummyProviderData({
|
||||||
|
includeProperties: true,
|
||||||
|
connected: false,
|
||||||
|
state: 'initializing',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /external-secrets/providers/:provider/test', () => {
|
||||||
|
test('can test provider', async () => {
|
||||||
|
const testData = {
|
||||||
|
username: 'testuser',
|
||||||
|
other: 'testother',
|
||||||
|
};
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
expect(resp.body.data.success).toBe(true);
|
||||||
|
expect(resp.body.data.testState).toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can test provider fail', async () => {
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: TestFailProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
username: 'testuser',
|
||||||
|
other: 'testother',
|
||||||
|
};
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData);
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
expect(resp.body.data.success).toBe(false);
|
||||||
|
expect(resp.body.data.testState).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /external-secrets/providers/:provider/update', () => {
|
||||||
|
test('can update provider', async () => {
|
||||||
|
const updateSpy = jest.spyOn(
|
||||||
|
Container.get(ExternalSecretsManager).getProvider('dummy')!,
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
expect(resp.body.data).toEqual({ updated: true });
|
||||||
|
expect(updateSpy).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can not update errored provider', async () => {
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: FailedProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetManager();
|
||||||
|
|
||||||
|
const updateSpy = jest.spyOn(
|
||||||
|
Container.get(ExternalSecretsManager).getProvider('dummy')!,
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
expect(resp.body.data).toEqual({ updated: false });
|
||||||
|
expect(updateSpy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can not update provider without a valid license', async () => {
|
||||||
|
const updateSpy = jest.spyOn(
|
||||||
|
Container.get(ExternalSecretsManager).getProvider('dummy')!,
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseLike.isExternalSecretsEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update');
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
expect(resp.body.data).toEqual({ updated: false });
|
||||||
|
expect(updateSpy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /external-secrets/secrets', () => {
|
||||||
|
test('can get secret names as owner', async () => {
|
||||||
|
const resp = await authOwnerAgent.get('/external-secrets/secrets');
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
expect(resp.body.data).toEqual({
|
||||||
|
dummy: ['test1', 'test2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can not get secret names as non-owner', async () => {
|
||||||
|
const resp = await authMemberAgent.get('/external-secrets/secrets');
|
||||||
|
expect(resp.status).toBe(403);
|
||||||
|
expect(resp.body.data).not.toEqual({
|
||||||
|
dummy: ['test1', 'test2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -26,6 +26,7 @@ export type EndpointGroup =
|
||||||
| 'license'
|
| 'license'
|
||||||
| 'variables'
|
| 'variables'
|
||||||
| 'tags'
|
| 'tags'
|
||||||
|
| 'externalSecrets'
|
||||||
| 'mfa'
|
| 'mfa'
|
||||||
| 'metrics';
|
| 'metrics';
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,7 @@ import * as testDb from '../../shared/testDb';
|
||||||
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
|
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
|
||||||
import type { EndpointGroup, SetupProps, TestServer } from '../types';
|
import type { EndpointGroup, SetupProps, TestServer } from '../types';
|
||||||
import { mockInstance } from './mocking';
|
import { mockInstance } from './mocking';
|
||||||
|
import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee';
|
||||||
import { MfaService } from '@/Mfa/mfa.service';
|
import { MfaService } from '@/Mfa/mfa.service';
|
||||||
import { TOTPService } from '@/Mfa/totp.service';
|
import { TOTPService } from '@/Mfa/totp.service';
|
||||||
import { UserSettings } from 'n8n-core';
|
import { UserSettings } from 'n8n-core';
|
||||||
|
@ -285,6 +286,9 @@ export const setupTestServer = ({
|
||||||
case 'tags':
|
case 'tags':
|
||||||
registerController(app, config, Container.get(TagsController));
|
registerController(app, config, Container.get(TagsController));
|
||||||
break;
|
break;
|
||||||
|
case 'externalSecrets':
|
||||||
|
registerController(app, config, Container.get(ExternalSecretsController));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
215
packages/cli/test/shared/ExternalSecrets/utils.ts
Normal file
215
packages/cli/test/shared/ExternalSecrets/utils.ts
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import { SecretsProvider } from '@/Interfaces';
|
||||||
|
import type { SecretsProviderSettings, SecretsProviderState } from '@/Interfaces';
|
||||||
|
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class MockProviders {
|
||||||
|
providers: Record<string, { new (): SecretsProvider }> = {
|
||||||
|
dummy: DummyProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProviders(providers: Record<string, { new (): SecretsProvider }>) {
|
||||||
|
this.providers = providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvider(name: string): { new (): SecretsProvider } | null {
|
||||||
|
return this.providers[name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProvider(name: string) {
|
||||||
|
return name in this.providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllProviders() {
|
||||||
|
return this.providers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DummyProvider extends SecretsProvider {
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
name: 'username',
|
||||||
|
displayName: 'Username',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'other',
|
||||||
|
displayName: 'Other',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
displayName: 'Password',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
typeOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
secrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
displayName = 'Dummy Provider';
|
||||||
|
|
||||||
|
name = 'dummy';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
_updateSecrets: Record<string, string> = {
|
||||||
|
test1: 'value1',
|
||||||
|
test2: 'value2',
|
||||||
|
};
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings<IDataObject>): Promise<void> {}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.state = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
this.secrets = this._updateSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
return [true];
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject | undefined {
|
||||||
|
return this.secrets[name] as unknown as IDataObject | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
return name in this.secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
return Object.keys(this.secrets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorProvider extends SecretsProvider {
|
||||||
|
secrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
displayName = 'Error Provider';
|
||||||
|
|
||||||
|
name = 'dummy';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings<IDataObject>): Promise<void> {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.state = 'error';
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject | undefined {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FailedProvider extends SecretsProvider {
|
||||||
|
secrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
displayName = 'Failed Provider';
|
||||||
|
|
||||||
|
name = 'dummy';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings<IDataObject>): Promise<void> {}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.state = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
|
||||||
|
async update(): Promise<void> {}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
return [true];
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject | undefined {
|
||||||
|
return this.secrets[name] as unknown as IDataObject | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
return name in this.secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
return Object.keys(this.secrets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestFailProvider extends SecretsProvider {
|
||||||
|
secrets: Record<string, string> = {};
|
||||||
|
|
||||||
|
displayName = 'Test Failed Provider';
|
||||||
|
|
||||||
|
name = 'dummy';
|
||||||
|
|
||||||
|
state: SecretsProviderState = 'initializing';
|
||||||
|
|
||||||
|
_updateSecrets: Record<string, string> = {
|
||||||
|
test1: 'value1',
|
||||||
|
test2: 'value2',
|
||||||
|
};
|
||||||
|
|
||||||
|
async init(settings: SecretsProviderSettings<IDataObject>): Promise<void> {}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.state = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {}
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
this.secrets = this._updateSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<[boolean] | [boolean, string]> {
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecret(name: string): IDataObject | undefined {
|
||||||
|
return this.secrets[name] as unknown as IDataObject | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSecret(name: string): boolean {
|
||||||
|
return name in this.secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSecretNames(): string[] {
|
||||||
|
return Object.keys(this.secrets);
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import { mockInstance } from '../integration/shared/utils/';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { ActiveExecutions } from '@/ActiveExecutions';
|
import { ActiveExecutions } from '@/ActiveExecutions';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
|
import { SecretsHelper } from '@/SecretsHelpers';
|
||||||
import { WebhookService } from '@/services/webhook.service';
|
import { WebhookService } from '@/services/webhook.service';
|
||||||
import { VariablesService } from '../../src/environments/variables/variables.service';
|
import { VariablesService } from '../../src/environments/variables/variables.service';
|
||||||
|
|
||||||
|
@ -159,6 +160,7 @@ describe('ActiveWorkflowRunner', () => {
|
||||||
Container.set(LoadNodesAndCredentials, nodesAndCredentials);
|
Container.set(LoadNodesAndCredentials, nodesAndCredentials);
|
||||||
Container.set(VariablesService, mockVariablesService);
|
Container.set(VariablesService, mockVariablesService);
|
||||||
mockInstance(Push);
|
mockInstance(Push);
|
||||||
|
mockInstance(SecretsHelper);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -0,0 +1,194 @@
|
||||||
|
import type { SettingsRepository } from '@/databases/repositories';
|
||||||
|
import type { ExternalSecretsSettings } from '@/Interfaces';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee';
|
||||||
|
import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { UserSettings } from 'n8n-core';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import { mockInstance } from '../../integration/shared/utils';
|
||||||
|
import {
|
||||||
|
DummyProvider,
|
||||||
|
ErrorProvider,
|
||||||
|
FailedProvider,
|
||||||
|
MockProviders,
|
||||||
|
} from '../../shared/ExternalSecrets/utils';
|
||||||
|
import { AES, enc } from 'crypto-js';
|
||||||
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
|
||||||
|
const connectedDate = '2023-08-01T12:32:29.000Z';
|
||||||
|
const encryptionKey = 'testkey';
|
||||||
|
let settings: string | null = null;
|
||||||
|
const mockProvidersInstance = new MockProviders();
|
||||||
|
const settingsRepo = mock<SettingsRepository>({
|
||||||
|
async getEncryptedSecretsProviderSettings() {
|
||||||
|
return settings;
|
||||||
|
},
|
||||||
|
async saveEncryptedSecretsProviderSettings(data) {
|
||||||
|
settings = data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let licenseMock: License;
|
||||||
|
let providersMock: ExternalSecretsProviders;
|
||||||
|
let manager: ExternalSecretsManager | undefined;
|
||||||
|
|
||||||
|
const createMockSettings = (settings: ExternalSecretsSettings): string => {
|
||||||
|
return AES.encrypt(JSON.stringify(settings), encryptionKey).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptSettings = (settings: string) => {
|
||||||
|
return JSON.parse(AES.decrypt(settings ?? '', encryptionKey).toString(enc.Utf8));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('External Secrets Manager', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest
|
||||||
|
.spyOn(UserSettings, 'getEncryptionKey')
|
||||||
|
.mockReturnValue(new Promise((resolve) => resolve(encryptionKey)));
|
||||||
|
providersMock = mockInstance(ExternalSecretsProviders, mockProvidersInstance);
|
||||||
|
licenseMock = mockInstance(License, {
|
||||||
|
isExternalSecretsEnabled() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockInstance(InternalHooks);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: DummyProvider,
|
||||||
|
});
|
||||||
|
settings = createMockSettings({
|
||||||
|
dummy: { connected: true, connectedAt: new Date(connectedDate), settings: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
Container.remove(ExternalSecretsManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
manager?.shutdown();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get secret', async () => {
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
expect(manager.getSecret('dummy', 'test1')).toBe('value1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not throw errors during init', async () => {
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: ErrorProvider,
|
||||||
|
});
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
expect(async () => manager!.init()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not throw errors during shutdown', async () => {
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: ErrorProvider,
|
||||||
|
});
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
expect(() => manager!.shutdown()).not.toThrow();
|
||||||
|
manager = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should save provider settings', async () => {
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
const settingsSpy = jest.spyOn(settingsRepo, 'saveEncryptedSecretsProviderSettings');
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
await manager.setProviderSettings('dummy', {
|
||||||
|
test: 'value',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decryptSettings(settingsSpy.mock.calls[0][0])).toEqual({
|
||||||
|
dummy: {
|
||||||
|
connected: true,
|
||||||
|
connectedAt: connectedDate,
|
||||||
|
settings: {
|
||||||
|
test: 'value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call provider update functions on a timer', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update');
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(0);
|
||||||
|
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not call provider update functions if the not licensed', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
manager = new ExternalSecretsManager(
|
||||||
|
settingsRepo,
|
||||||
|
mock<License>({
|
||||||
|
isExternalSecretsEnabled() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
providersMock,
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update');
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(0);
|
||||||
|
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not call provider update functions if the provider has an error', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
mockProvidersInstance.setProviders({
|
||||||
|
dummy: FailedProvider,
|
||||||
|
});
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update');
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(0);
|
||||||
|
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
expect(updateSpy).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reinitialize a provider when save provider settings', async () => {
|
||||||
|
manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock);
|
||||||
|
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const dummyInitSpy = jest.spyOn(DummyProvider.prototype, 'init');
|
||||||
|
|
||||||
|
await manager.setProviderSettings('dummy', {
|
||||||
|
test: 'value',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dummyInitSpy).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -136,6 +136,7 @@ import {
|
||||||
setAllWorkflowExecutionMetadata,
|
setAllWorkflowExecutionMetadata,
|
||||||
setWorkflowExecutionMetadata,
|
setWorkflowExecutionMetadata,
|
||||||
} from './WorkflowExecutionMetadata';
|
} from './WorkflowExecutionMetadata';
|
||||||
|
import { getSecretsProxy } from './Secrets';
|
||||||
import { getUserN8nFolderPath } from './UserSettings';
|
import { getUserN8nFolderPath } from './UserSettings';
|
||||||
|
|
||||||
axios.defaults.timeout = 300000;
|
axios.defaults.timeout = 300000;
|
||||||
|
@ -1683,6 +1684,7 @@ export function getAdditionalKeys(
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
runExecutionData: IRunExecutionData | null,
|
runExecutionData: IRunExecutionData | null,
|
||||||
|
options?: { secretsEnabled?: boolean },
|
||||||
): IWorkflowDataProxyAdditionalKeys {
|
): IWorkflowDataProxyAdditionalKeys {
|
||||||
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
|
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
|
||||||
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
||||||
|
@ -1723,6 +1725,7 @@ export function getAdditionalKeys(
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
$vars: additionalData.variables,
|
$vars: additionalData.variables,
|
||||||
|
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
|
||||||
|
|
||||||
// deprecated
|
// deprecated
|
||||||
$executionId: executionId,
|
$executionId: executionId,
|
||||||
|
@ -1858,6 +1861,7 @@ export async function getCredentials(
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
|
const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
|
||||||
|
additionalData,
|
||||||
nodeCredentials,
|
nodeCredentials,
|
||||||
type,
|
type,
|
||||||
mode,
|
mode,
|
||||||
|
|
76
packages/core/src/Secrets.ts
Normal file
76
packages/core/src/Secrets.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import type { IDataObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
import { ExpressionError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
function buildSecretsValueProxy(value: IDataObject): unknown {
|
||||||
|
return new Proxy(value, {
|
||||||
|
get(target, valueName) {
|
||||||
|
if (typeof valueName !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(valueName in value)) {
|
||||||
|
throw new ExpressionError('Could not load secrets', {
|
||||||
|
description:
|
||||||
|
'The credential in use tries to use secret from an external store that could not be found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const retValue = value[valueName];
|
||||||
|
if (typeof retValue === 'object' && retValue !== null) {
|
||||||
|
return buildSecretsValueProxy(retValue as IDataObject);
|
||||||
|
}
|
||||||
|
return retValue;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject {
|
||||||
|
const secretsHelpers = additionalData.secretsHelpers;
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(target, providerName) {
|
||||||
|
if (typeof providerName !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (secretsHelpers.hasProvider(providerName)) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(target2, secretName): IDataObject | undefined {
|
||||||
|
if (typeof secretName !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!secretsHelpers.hasSecret(providerName, secretName)) {
|
||||||
|
throw new ExpressionError('Could not load secrets', {
|
||||||
|
description:
|
||||||
|
'The credential in use tries to use secret from an external store that could not be found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const retValue = secretsHelpers.getSecret(providerName, secretName);
|
||||||
|
if (typeof retValue === 'object' && retValue !== null) {
|
||||||
|
return buildSecretsValueProxy(retValue) as IDataObject;
|
||||||
|
}
|
||||||
|
return retValue;
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return secretsHelpers.listSecrets(providerName);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new ExpressionError('Could not load secrets', {
|
||||||
|
description:
|
||||||
|
'The credential in use pulls secrets from an external store that is not reachable',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return secretsHelpers.listProviders();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ const CALLOUT_DEFAULT_ICONS: { [key: string]: string } = {
|
||||||
info: 'info-circle',
|
info: 'info-circle',
|
||||||
success: 'check-circle',
|
success: 'check-circle',
|
||||||
warning: 'exclamation-triangle',
|
warning: 'exclamation-triangle',
|
||||||
danger: 'times-circle',
|
danger: 'exclamation-triangle',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
|
@ -27,7 +27,7 @@ exports[`components > N8nCallout > should render danger theme correctly 1`] = `
|
||||||
"<div class=\\"n8n-callout callout danger round\\" role=\\"alert\\">
|
"<div class=\\"n8n-callout callout danger round\\" role=\\"alert\\">
|
||||||
<div class=\\"messageSection\\">
|
<div class=\\"messageSection\\">
|
||||||
<div class=\\"icon\\">
|
<div class=\\"icon\\">
|
||||||
<n8n-icon-stub icon=\\"times-circle\\" size=\\"medium\\" spin=\\"false\\"></n8n-icon-stub>
|
<n8n-icon-stub icon=\\"exclamation-triangle\\" size=\\"medium\\" spin=\\"false\\"></n8n-icon-stub>
|
||||||
</div>
|
</div>
|
||||||
<n8n-text-stub bold=\\"false\\" size=\\"small\\" compact=\\"false\\" tag=\\"span\\"></n8n-text-stub>
|
<n8n-text-stub bold=\\"false\\" size=\\"small\\" compact=\\"false\\" tag=\\"span\\"></n8n-text-stub>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
.el-loading-spinner {
|
.el-loading-spinner {
|
||||||
margin-top: #{- var.$loading-fullscreen-spinner-size * 0.5};
|
transform: translateY(-50%);
|
||||||
|
|
||||||
.circular {
|
.circular {
|
||||||
height: var.$loading-fullscreen-spinner-size;
|
height: var.$loading-fullscreen-spinner-size;
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
|
|
||||||
@include mixins.b(loading-spinner) {
|
@include mixins.b(loading-spinner) {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
margin-top: #{- var.$loading-spinner-size * 0.5};
|
transform: translateY(-50%);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
10
packages/design-system/src/css/utilities/_link.scss
Normal file
10
packages/design-system/src/css/utilities/_link.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.overlay-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
@import 'float';
|
@import 'float';
|
||||||
|
@import 'link';
|
||||||
@import 'list';
|
@import 'list';
|
||||||
@import 'spacing';
|
@import 'spacing';
|
||||||
@import 'typography';
|
@import 'typography';
|
||||||
|
|
|
@ -44,6 +44,7 @@ import type {
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy } from '@/utils/typeHelpers';
|
import type { PartialBy } from '@/utils/typeHelpers';
|
||||||
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
export * from 'n8n-design-system/types';
|
export * from 'n8n-design-system/types';
|
||||||
|
|
||||||
|
@ -1542,6 +1543,26 @@ export interface InstanceUsage {
|
||||||
|
|
||||||
export type CloudPlanAndUsageData = Cloud.PlanData & { usage: InstanceUsage };
|
export type CloudPlanAndUsageData = Cloud.PlanData & { usage: InstanceUsage };
|
||||||
|
|
||||||
|
export interface ExternalSecretsProviderSecret {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalSecretsProviderData = Record<string, IUpdateInformation['value']>;
|
||||||
|
|
||||||
|
export interface ExternalSecretsProvider {
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
connected: boolean;
|
||||||
|
connectedAt: string | false;
|
||||||
|
state: 'connected' | 'tested' | 'initializing' | 'error';
|
||||||
|
data?: ExternalSecretsProviderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalSecretsProviderWithProperties extends ExternalSecretsProvider {
|
||||||
|
properties: INodeProperties[];
|
||||||
|
}
|
||||||
|
|
||||||
export type CloudUpdateLinkSourceType =
|
export type CloudUpdateLinkSourceType =
|
||||||
| 'canvas-nav'
|
| 'canvas-nav'
|
||||||
| 'custom-data-filter'
|
| 'custom-data-filter'
|
||||||
|
|
58
packages/editor-ui/src/api/externalSecrets.ee.ts
Normal file
58
packages/editor-ui/src/api/externalSecrets.ee.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import type {
|
||||||
|
IRestApiContext,
|
||||||
|
ExternalSecretsProvider,
|
||||||
|
ExternalSecretsProviderWithProperties,
|
||||||
|
} from '@/Interface';
|
||||||
|
import { makeRestApiRequest } from '@/utils';
|
||||||
|
|
||||||
|
export const getExternalSecrets = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
): Promise<Record<string, string[]>> => {
|
||||||
|
return makeRestApiRequest(context, 'GET', '/external-secrets/secrets');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExternalSecretsProviders = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
): Promise<ExternalSecretsProvider[]> => {
|
||||||
|
return makeRestApiRequest(context, 'GET', '/external-secrets/providers');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExternalSecretsProvider = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<ExternalSecretsProviderWithProperties> => {
|
||||||
|
return makeRestApiRequest(context, 'GET', `/external-secrets/providers/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testExternalSecretsProviderConnection = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
data: ExternalSecretsProvider['data'],
|
||||||
|
): Promise<{ testState: ExternalSecretsProvider['state'] }> => {
|
||||||
|
return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/test`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProvider = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
data: ExternalSecretsProvider['data'],
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reloadProvider = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ updated: boolean }> => {
|
||||||
|
return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectProvider = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
connected: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/connect`, {
|
||||||
|
connected,
|
||||||
|
});
|
||||||
|
};
|
BIN
packages/editor-ui/src/assets/images/doppler.webp
Normal file
BIN
packages/editor-ui/src/assets/images/doppler.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 308 KiB |
BIN
packages/editor-ui/src/assets/images/hashicorp.webp
Normal file
BIN
packages/editor-ui/src/assets/images/hashicorp.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/editor-ui/src/assets/images/infisical.webp
Normal file
BIN
packages/editor-ui/src/assets/images/infisical.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
|
@ -172,6 +172,7 @@ export const completerExtension = defineComponent({
|
||||||
|
|
||||||
if (value === '$execution') return this.executionCompletions(context, variable);
|
if (value === '$execution') return this.executionCompletions(context, variable);
|
||||||
if (value === '$vars') return this.variablesCompletions(context, variable);
|
if (value === '$vars') return this.variablesCompletions(context, variable);
|
||||||
|
|
||||||
if (value === '$workflow') return this.workflowCompletions(context, variable);
|
if (value === '$workflow') return this.workflowCompletions(context, variable);
|
||||||
if (value === '$prevNode') return this.prevNodeCompletions(context, variable);
|
if (value === '$prevNode') return this.prevNodeCompletions(context, variable);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import { addVarType } from '../utils';
|
||||||
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import type { CodeNodeEditorMixin } from '../types';
|
||||||
|
import { useExternalSecretsStore } from '@/stores';
|
||||||
|
|
||||||
|
const escape = (str: string) => str.replace('$', '\\$');
|
||||||
|
|
||||||
|
export const secretsCompletions = (Vue as CodeNodeEditorMixin).extend({
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`.
|
||||||
|
*/
|
||||||
|
secretsCompletions(context: CompletionContext, matcher = '$secrets'): CompletionResult | null {
|
||||||
|
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||||
|
const preCursor = context.matchBefore(pattern);
|
||||||
|
|
||||||
|
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||||
|
|
||||||
|
const provider = preCursor.text.split('.')[1];
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
let options: Completion[];
|
||||||
|
|
||||||
|
const optionsForObject = (leftSide: string, object: object): Completion[] => {
|
||||||
|
return Object.entries(object).flatMap(([key, value]) => {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return optionsForObject(`${leftSide}.${key}`, value);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: `${leftSide}.${key}`,
|
||||||
|
info: '*******',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
options = optionsForObject(
|
||||||
|
`${matcher}.${provider}`,
|
||||||
|
externalSecretsStore.secretsAsObject[provider],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
options = Object.keys(externalSecretsStore.secretsAsObject).map((provider) => ({
|
||||||
|
label: `${matcher}.${provider}`,
|
||||||
|
info: JSON.stringify(externalSecretsStore.secretsAsObject[provider]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: preCursor.from,
|
||||||
|
options: options.map(addVarType),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -8,7 +8,7 @@ const escape = (str: string) => str.replace('$', '\\$');
|
||||||
export const variablesCompletions = defineComponent({
|
export const variablesCompletions = defineComponent({
|
||||||
methods: {
|
methods: {
|
||||||
/**
|
/**
|
||||||
* Complete `$workflow.` to `.id .name .active`.
|
* Complete `$vars.` to `$vars.VAR_NAME`.
|
||||||
*/
|
*/
|
||||||
variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null {
|
variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null {
|
||||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||||
|
|
|
@ -126,6 +126,17 @@
|
||||||
<n8n-text v-if="isMissingCredentials" color="text-base" size="medium">
|
<n8n-text v-if="isMissingCredentials" color="text-base" size="medium">
|
||||||
{{ $locale.baseText('credentialEdit.credentialConfig.missingCredentialType') }}
|
{{ $locale.baseText('credentialEdit.credentialConfig.missingCredentialType') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
|
|
||||||
|
<EnterpriseEdition :features="[EnterpriseEditionFeature.ExternalSecrets]">
|
||||||
|
<template #fallback>
|
||||||
|
<n8n-info-tip class="mt-s">
|
||||||
|
{{ $locale.baseText('credentialEdit.credentialConfig.externalSecrets') }}
|
||||||
|
<n8n-link bold :to="$locale.baseText('settings.externalSecrets.docs')" size="small">
|
||||||
|
{{ $locale.baseText('credentialEdit.credentialConfig.externalSecrets.moreInfo') }}
|
||||||
|
</n8n-link>
|
||||||
|
</n8n-info-tip>
|
||||||
|
</template>
|
||||||
|
</EnterpriseEdition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -152,10 +163,12 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import type { ICredentialsResponse } from '@/Interface';
|
import type { ICredentialsResponse } from '@/Interface';
|
||||||
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
|
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
|
||||||
import GoogleAuthButton from './GoogleAuthButton.vue';
|
import GoogleAuthButton from './GoogleAuthButton.vue';
|
||||||
|
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'CredentialConfig',
|
name: 'CredentialConfig',
|
||||||
components: {
|
components: {
|
||||||
|
EnterpriseEdition,
|
||||||
AuthTypeSelector,
|
AuthTypeSelector,
|
||||||
Banner,
|
Banner,
|
||||||
CopyInput,
|
CopyInput,
|
||||||
|
|
|
@ -157,6 +157,8 @@ import {
|
||||||
getNodeCredentialForSelectedAuthType,
|
getNodeCredentialForSelectedAuthType,
|
||||||
updateNodeAuthType,
|
updateNodeAuthType,
|
||||||
isCredentialModalState,
|
isCredentialModalState,
|
||||||
|
isExpression,
|
||||||
|
isTestableExpression,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
import { externalHooks } from '@/mixins/externalHooks';
|
import { externalHooks } from '@/mixins/externalHooks';
|
||||||
|
|
||||||
|
@ -370,12 +372,13 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ownedBy, sharedWith, ...credentialData } = this.credentialData;
|
const { ownedBy, sharedWith, ...credentialData } = this.credentialData;
|
||||||
const hasExpressions = Object.values(credentialData).reduce(
|
const hasUntestableExpressions = Object.values(credentialData).reduce(
|
||||||
(accu: boolean, value: CredentialInformation) =>
|
(accu: boolean, value: CredentialInformation) =>
|
||||||
accu || (typeof value === 'string' && value.startsWith('=')),
|
accu ||
|
||||||
|
(typeof value === 'string' && isExpression(value) && !isTestableExpression(value)),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
if (hasExpressions) {
|
if (hasUntestableExpressions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,8 +448,14 @@ export default defineComponent({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (property.type === 'number' && typeof this.credentialData[property.name] !== 'number') {
|
if (property.type === 'number') {
|
||||||
return false;
|
const isExpression =
|
||||||
|
typeof this.credentialData[property.name] === 'string' &&
|
||||||
|
this.credentialData[property.name].startsWith('=');
|
||||||
|
|
||||||
|
if (typeof this.credentialData[property.name] !== 'number' && !isExpression) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
@ -835,12 +844,17 @@ export default defineComponent({
|
||||||
this.testedSuccessfully = false;
|
this.testedSuccessfully = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usesExternalSecrets = Object.entries(credentialDetails.data || {}).some(([, value]) =>
|
||||||
|
/=.*\{\{[^}]*\$secrets\.[^}]+}}.*/.test(`${value}`),
|
||||||
|
);
|
||||||
|
|
||||||
const trackProperties: ITelemetryTrackProperties = {
|
const trackProperties: ITelemetryTrackProperties = {
|
||||||
credential_type: credentialDetails.type,
|
credential_type: credentialDetails.type,
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
credential_id: credential.id,
|
credential_id: credential.id,
|
||||||
is_complete: !!this.requiredPropertiesFilled,
|
is_complete: !!this.requiredPropertiesFilled,
|
||||||
is_new: isNewCredential,
|
is_new: isNewCredential,
|
||||||
|
uses_external_secrets: usesExternalSecrets,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.isOAuthType) {
|
if (this.isOAuthType) {
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
:isReadOnly="isReadOnly"
|
:isReadOnly="isReadOnly"
|
||||||
:targetItem="hoveringItem"
|
:targetItem="hoveringItem"
|
||||||
:isSingleLine="isForRecordLocator"
|
:isSingleLine="isForRecordLocator"
|
||||||
|
:additionalData="additionalExpressionData"
|
||||||
:path="path"
|
:path="path"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
|
@ -34,7 +35,6 @@
|
||||||
data-test-id="expander"
|
data-test-id="expander"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineExpressionEditorOutput
|
<InlineExpressionEditorOutput
|
||||||
:segments="segments"
|
:segments="segments"
|
||||||
:isReadOnly="isReadOnly"
|
:isReadOnly="isReadOnly"
|
||||||
|
@ -46,6 +46,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
@ -57,6 +58,7 @@ import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||||
|
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import type { TargetItem } from '@/Interface';
|
import type { TargetItem } from '@/Interface';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
type InlineExpressionEditorInputRef = InstanceType<typeof InlineExpressionEditorInput>;
|
type InlineExpressionEditorInputRef = InstanceType<typeof InlineExpressionEditorInput>;
|
||||||
|
|
||||||
|
@ -88,6 +90,10 @@ export default defineComponent({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
additionalExpressionData: {
|
||||||
|
type: Object as PropType<IDataObject>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNDVStore, useWorkflowsStore),
|
...mapStores(useNDVStore, useWorkflowsStore),
|
||||||
|
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropType, Ref } from 'vue';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
import ExternalSecretsProviderImage from '@/components/ExternalSecretsProviderImage.ee.vue';
|
||||||
|
import ExternalSecretsProviderConnectionSwitch from '@/components/ExternalSecretsProviderConnectionSwitch.ee.vue';
|
||||||
|
import { useExternalSecretsStore, useUIStore } from '@/stores';
|
||||||
|
import { useExternalSecretsProvider, useI18n, useToast } from '@/composables';
|
||||||
|
import { EXTERNAL_SECRETS_PROVIDER_MODAL_KEY } from '@/constants';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { computed, nextTick, onMounted, toRefs } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
provider: {
|
||||||
|
type: Object as PropType<ExternalSecretsProvider>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { provider } = toRefs(props) as Ref<ExternalSecretsProvider>;
|
||||||
|
const providerData = computed(() => provider.value.data);
|
||||||
|
const {
|
||||||
|
connectionState,
|
||||||
|
initialConnectionState,
|
||||||
|
normalizedProviderData,
|
||||||
|
testConnection,
|
||||||
|
setConnectionState,
|
||||||
|
} = useExternalSecretsProvider(provider, providerData);
|
||||||
|
|
||||||
|
const actionDropdownOptions = computed(() => [
|
||||||
|
{
|
||||||
|
value: 'setup',
|
||||||
|
label: i18n.baseText('settings.externalSecrets.card.actionDropdown.setup'),
|
||||||
|
},
|
||||||
|
...(props.provider.connected
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: 'reload',
|
||||||
|
label: i18n.baseText('settings.externalSecrets.card.actionDropdown.reload'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const canConnect = computed(() => {
|
||||||
|
return props.provider.connected || Object.keys(props.provider.data).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed((provider: ExternalSecretsProvider) => {
|
||||||
|
return DateTime.fromISO(props.provider.connectedAt!).toFormat('dd LLL yyyy');
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setConnectionState(props.provider.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onBeforeConnectionUpdate() {
|
||||||
|
if (props.provider.connected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await externalSecretsStore.getProvider(props.provider.name);
|
||||||
|
await nextTick();
|
||||||
|
const status = await testConnection();
|
||||||
|
|
||||||
|
return status !== 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalSecretProvider() {
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
|
data: { name: props.provider.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadProvider() {
|
||||||
|
try {
|
||||||
|
await externalSecretsStore.reloadProvider(props.provider.name);
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('settings.externalSecrets.card.reload.success.title'),
|
||||||
|
message: i18n.baseText('settings.externalSecrets.card.reload.success.description', {
|
||||||
|
interpolate: { provider: props.provider.displayName },
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onActionDropdownClick(id: string) {
|
||||||
|
switch (id) {
|
||||||
|
case 'setup':
|
||||||
|
openExternalSecretProvider();
|
||||||
|
break;
|
||||||
|
case 'reload':
|
||||||
|
await reloadProvider();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-card :class="$style.card">
|
||||||
|
<div :class="$style.cardBody">
|
||||||
|
<ExternalSecretsProviderImage :class="$style.cardImage" :provider="provider" />
|
||||||
|
<div :class="$style.cardContent">
|
||||||
|
<n8n-text bold>{{ provider.displayName }}</n8n-text>
|
||||||
|
<n8n-text color="text-light" size="small" v-if="provider.connected">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
i18n.baseText('settings.externalSecrets.card.secretsCount', {
|
||||||
|
interpolate: {
|
||||||
|
count: `${externalSecretsStore.secrets[provider.name]?.length}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
|
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
i18n.baseText('settings.externalSecrets.card.connectedAt', {
|
||||||
|
interpolate: {
|
||||||
|
date: formattedDate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.cardActions" v-if="canConnect">
|
||||||
|
<ExternalSecretsProviderConnectionSwitch
|
||||||
|
:provider="provider"
|
||||||
|
:beforeUpdate="onBeforeConnectionUpdate"
|
||||||
|
:disabled="connectionState === 'error' && !provider.connected"
|
||||||
|
/>
|
||||||
|
<n8n-action-toggle
|
||||||
|
class="ml-s"
|
||||||
|
theme="dark"
|
||||||
|
:actions="actionDropdownOptions"
|
||||||
|
@action="onActionDropdownClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n8n-button v-else type="tertiary" @click="openExternalSecretProvider()">
|
||||||
|
{{ i18n.baseText('settings.externalSecrets.card.setUp') }}
|
||||||
|
</n8n-button>
|
||||||
|
</div>
|
||||||
|
</n8n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardImage {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,116 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
import { useExternalSecretsStore } from '@/stores';
|
||||||
|
import { useI18n, useLoadingService, useToast } from '@/composables';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'change', value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
provider: {
|
||||||
|
type: Object as PropType<ExternalSecretsProvider>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
eventBus: {
|
||||||
|
type: Object as PropType<EventBus>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
beforeUpdate: {
|
||||||
|
type: Function,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingService = useLoadingService();
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const connectedTextColor = computed(() => {
|
||||||
|
return props.provider.connected ? 'success' : 'text-light';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.eventBus) {
|
||||||
|
props.eventBus.on('connect', onUpdateConnected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onUpdateConnected(value: boolean) {
|
||||||
|
try {
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
if (props.beforeUpdate) {
|
||||||
|
const result = await props.beforeUpdate(value);
|
||||||
|
if (result === false) {
|
||||||
|
saving.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await externalSecretsStore.updateProviderConnected(props.provider.name, value);
|
||||||
|
|
||||||
|
emit('change', value);
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, 'Error');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="connection-switch" v-loading="saving" element-loading-spinner="el-icon-loading">
|
||||||
|
<n8n-icon
|
||||||
|
v-if="provider.state === 'error'"
|
||||||
|
color="danger"
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
class="mr-2xs"
|
||||||
|
/>
|
||||||
|
<n8n-text :color="connectedTextColor" bold class="mr-2xs">
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.externalSecrets.card.${provider.connected ? 'connected' : 'disconnected'}`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-text>
|
||||||
|
<el-switch
|
||||||
|
:modelValue="provider.connected"
|
||||||
|
:title="
|
||||||
|
i18n.baseText('settings.externalSecrets.card.connectedSwitch.title', {
|
||||||
|
interpolate: { provider: provider.displayName },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="disabled"
|
||||||
|
data-test-id="settings-external-secrets-connected-switch"
|
||||||
|
@update:modelValue="onUpdateConnected"
|
||||||
|
>
|
||||||
|
</el-switch>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.connection-switch {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
:deep(.el-switch.is-checked .el-switch__core) {
|
||||||
|
background-color: #ff4027;
|
||||||
|
border-color: #ff4027;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import infisical from '../assets/images/infisical.webp';
|
||||||
|
import doppler from '../assets/images/doppler.webp';
|
||||||
|
import vault from '../assets/images/hashicorp.webp';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
provider: {
|
||||||
|
type: Object as PropType<ExternalSecretsProvider>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
doppler,
|
||||||
|
infisical,
|
||||||
|
vault,
|
||||||
|
})[props.provider.name],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img :src="image" :alt="provider.displayName" width="28" height="28" />
|
||||||
|
</template>
|
|
@ -0,0 +1,331 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import type { PropType, Ref } from 'vue';
|
||||||
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
|
import { useExternalSecretsProvider, useI18n, useMessage, useToast } from '@/composables';
|
||||||
|
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||||
|
import { useUIStore } from '@/stores';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import ParameterInputExpanded from '@/components/ParameterInputExpanded.vue';
|
||||||
|
import type { IUpdateInformation, ExternalSecretsProviderData } from '@/Interface';
|
||||||
|
import type { IParameterLabel } from 'n8n-workflow';
|
||||||
|
import ExternalSecretsProviderImage from '@/components/ExternalSecretsProviderImage.ee.vue';
|
||||||
|
import ExternalSecretsProviderConnectionSwitch from '@/components/ExternalSecretsProviderConnectionSwitch.ee.vue';
|
||||||
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object as PropType<{ eventBus: EventBus; name: string }>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProviderData = {
|
||||||
|
infisical: {
|
||||||
|
siteURL: 'https://app.infisical.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const { confirm } = useMessage();
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const eventBus = createEventBus();
|
||||||
|
|
||||||
|
const labelSize: IParameterLabel = { size: 'medium' };
|
||||||
|
|
||||||
|
const provider = computed<ExternalSecretsProvider | undefined>(() =>
|
||||||
|
externalSecretsStore.providers.find((p) => p.name === props.data.name),
|
||||||
|
) as Ref<ExternalSecretsProvider>;
|
||||||
|
const providerData = ref<ExternalSecretsProviderData>({});
|
||||||
|
const {
|
||||||
|
connectionState,
|
||||||
|
initialConnectionState,
|
||||||
|
normalizedProviderData,
|
||||||
|
shouldDisplayProperty,
|
||||||
|
setConnectionState,
|
||||||
|
testConnection,
|
||||||
|
} = useExternalSecretsProvider(provider, providerData);
|
||||||
|
|
||||||
|
const providerDataUpdated = computed(() => {
|
||||||
|
return Object.keys(providerData.value).find((key) => {
|
||||||
|
const value = providerData.value[key];
|
||||||
|
const originalValue = provider.value.data[key];
|
||||||
|
|
||||||
|
return value !== originalValue;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = computed(
|
||||||
|
() =>
|
||||||
|
provider.value.properties
|
||||||
|
?.filter((property) => property.required && shouldDisplayProperty(property))
|
||||||
|
.every((property) => {
|
||||||
|
const value = providerData.value[property.name];
|
||||||
|
return !!value;
|
||||||
|
}) && providerDataUpdated.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const provider = await externalSecretsStore.getProvider(props.data.name);
|
||||||
|
providerData.value = {
|
||||||
|
...(defaultProviderData[props.data.name] || {}),
|
||||||
|
...provider.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
setConnectionState(provider.state);
|
||||||
|
|
||||||
|
if (provider.connected) {
|
||||||
|
initialConnectionState.value = provider.state;
|
||||||
|
} else if (Object.keys(provider.data).length) {
|
||||||
|
await testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.state === 'connected') {
|
||||||
|
void externalSecretsStore.reloadProvider(props.data.name);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, 'Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
uiStore.closeModal(EXTERNAL_SECRETS_PROVIDER_MODAL_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onValueChange(updateInformation: IUpdateInformation) {
|
||||||
|
providerData.value = {
|
||||||
|
...providerData.value,
|
||||||
|
[updateInformation.name]: updateInformation.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
saving.value = true;
|
||||||
|
await externalSecretsStore.updateProvider(provider.value.name, {
|
||||||
|
data: normalizedProviderData.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
setConnectionState(provider.value.state);
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, 'Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
await testConnection();
|
||||||
|
|
||||||
|
if (initialConnectionState.value === 'initializing' && connectionState.value === 'tested') {
|
||||||
|
setTimeout(() => {
|
||||||
|
eventBus.emit('connect', true);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBeforeClose() {
|
||||||
|
if (providerDataUpdated.value) {
|
||||||
|
const confirmModal = await confirm(
|
||||||
|
i18n.baseText('settings.externalSecrets.provider.closeWithoutSaving.description', {
|
||||||
|
interpolate: {
|
||||||
|
provider: provider.value.displayName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
title: i18n.baseText('settings.externalSecrets.provider.closeWithoutSaving.title'),
|
||||||
|
confirmButtonText: i18n.baseText(
|
||||||
|
'settings.externalSecrets.provider.closeWithoutSaving.confirm',
|
||||||
|
),
|
||||||
|
cancelButtonText: i18n.baseText(
|
||||||
|
'settings.externalSecrets.provider.closeWithoutSaving.cancel',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return confirmModal !== MODAL_CONFIRM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
id="external-secrets-provider-modal"
|
||||||
|
width="812px"
|
||||||
|
:title="provider.displayName"
|
||||||
|
:eventBus="data.eventBus"
|
||||||
|
:name="EXTERNAL_SECRETS_PROVIDER_MODAL_KEY"
|
||||||
|
:before-close="onBeforeClose"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div :class="$style.header">
|
||||||
|
<div :class="$style.providerTitle">
|
||||||
|
<ExternalSecretsProviderImage :provider="provider" class="mr-xs" />
|
||||||
|
<span>{{ provider.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.providerActions">
|
||||||
|
<ExternalSecretsProviderConnectionSwitch
|
||||||
|
class="mr-s"
|
||||||
|
:disabled="
|
||||||
|
(connectionState === 'initializing' || connectionState === 'error') &&
|
||||||
|
!provider.connected
|
||||||
|
"
|
||||||
|
:event-bus="eventBus"
|
||||||
|
:provider="provider"
|
||||||
|
@change="testConnection"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSave && !saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.externalSecrets.provider.buttons.${saving ? 'saving' : 'save'}`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<hr class="mb-l" />
|
||||||
|
<div class="mb-l" v-if="connectionState !== 'initializing'">
|
||||||
|
<n8n-callout
|
||||||
|
v-if="connectionState === 'connected' || connectionState === 'tested'"
|
||||||
|
theme="success"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.externalSecrets.provider.testConnection.success${
|
||||||
|
provider.connected ? '.connected' : ''
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
interpolate: {
|
||||||
|
count: `${externalSecretsStore.secrets[provider.name]?.length}`,
|
||||||
|
provider: provider.displayName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<span v-if="provider.connected">
|
||||||
|
<br />
|
||||||
|
<i18n-t
|
||||||
|
keypath="settings.externalSecrets.provider.testConnection.success.connected.usage"
|
||||||
|
>
|
||||||
|
<template #code>
|
||||||
|
<code>{{ `\{\{ \$secrets\.${provider.name}\.secret_name \}\}` }}</code>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
<n8n-link :href="i18n.baseText('settings.externalSecrets.docs.use')" size="small">
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
'settings.externalSecrets.provider.testConnection.success.connected.docs',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-link>
|
||||||
|
</span>
|
||||||
|
</n8n-callout>
|
||||||
|
<n8n-callout v-else-if="connectionState === 'error'" theme="danger">
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.externalSecrets.provider.testConnection.error${
|
||||||
|
provider.connected ? '.connected' : ''
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
interpolate: { provider: provider.displayName },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-callout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-for="property in provider.properties"
|
||||||
|
v-show="shouldDisplayProperty(property)"
|
||||||
|
:key="property.name"
|
||||||
|
autocomplete="off"
|
||||||
|
data-test-id="external-secrets-provider-properties-form"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
|
<n8n-notice v-if="property.type === 'notice'" :content="property.displayName" />
|
||||||
|
<parameter-input-expanded
|
||||||
|
v-else
|
||||||
|
class="mb-l"
|
||||||
|
:parameter="property"
|
||||||
|
:value="providerData[property.name]"
|
||||||
|
:label="labelSize"
|
||||||
|
eventSource="external-secrets-provider"
|
||||||
|
@update="onValueChange"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.container {
|
||||||
|
> * {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerActions {
|
||||||
|
flex: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#external-secrets-provider-modal {
|
||||||
|
.el-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__headerbtn {
|
||||||
|
position: relative;
|
||||||
|
top: unset;
|
||||||
|
right: unset;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||||
|
@ -18,6 +19,7 @@ import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expre
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { completionManager } from '@/mixins/completionManager';
|
import { completionManager } from '@/mixins/completionManager';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
const editableConf = new Compartment();
|
const editableConf = new Compartment();
|
||||||
|
|
||||||
|
@ -39,6 +41,10 @@ export default defineComponent({
|
||||||
path: {
|
path: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
additionalData: {
|
||||||
|
type: Object as PropType<IDataObject>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
isReadOnly(newValue: boolean) {
|
isReadOnly(newValue: boolean) {
|
||||||
|
@ -83,6 +89,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
const extensions = [
|
const extensions = [
|
||||||
|
n8nLang(),
|
||||||
inputTheme({ isSingleLine: this.isSingleLine }),
|
inputTheme({ isSingleLine: this.isSingleLine }),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
@ -100,7 +107,6 @@ export default defineComponent({
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
autocompletion(),
|
autocompletion(),
|
||||||
n8nLang(),
|
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
|
|
|
@ -120,6 +120,12 @@
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="EXTERNAL_SECRETS_PROVIDER_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, data }">
|
||||||
|
<ExternalSecretsProviderModal :modalName="modalName" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="DEBUG_PAYWALL_MODAL_KEY">
|
<ModalRoot :name="DEBUG_PAYWALL_MODAL_KEY">
|
||||||
<template #default="{ modalName, data }">
|
<template #default="{ modalName, data }">
|
||||||
<DebugPaywallModal data-test-id="debug-paywall-modal" :modalName="modalName" :data="data" />
|
<DebugPaywallModal data-test-id="debug-paywall-modal" :modalName="modalName" :data="data" />
|
||||||
|
@ -153,6 +159,7 @@ import {
|
||||||
LOG_STREAM_MODAL_KEY,
|
LOG_STREAM_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||||
|
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
DEBUG_PAYWALL_MODAL_KEY,
|
DEBUG_PAYWALL_MODAL_KEY,
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
@ -181,6 +188,7 @@ import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||||
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
|
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
|
||||||
|
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
|
||||||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
@ -209,6 +217,7 @@ export default defineComponent({
|
||||||
EventDestinationSettingsModal,
|
EventDestinationSettingsModal,
|
||||||
SourceControlPushModal,
|
SourceControlPushModal,
|
||||||
SourceControlPullModal,
|
SourceControlPullModal,
|
||||||
|
ExternalSecretsProviderModal,
|
||||||
DebugPaywallModal,
|
DebugPaywallModal,
|
||||||
MfaSetupModal,
|
MfaSetupModal,
|
||||||
},
|
},
|
||||||
|
@ -235,6 +244,7 @@ export default defineComponent({
|
||||||
LOG_STREAM_MODAL_KEY,
|
LOG_STREAM_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||||
|
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
DEBUG_PAYWALL_MODAL_KEY,
|
DEBUG_PAYWALL_MODAL_KEY,
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -46,6 +46,7 @@
|
||||||
:title="displayTitle"
|
:title="displayTitle"
|
||||||
:isReadOnly="isReadOnly"
|
:isReadOnly="isReadOnly"
|
||||||
:path="path"
|
:path="path"
|
||||||
|
:additional-expression-data="additionalExpressionData"
|
||||||
:class="{ 'ph-no-capture': shouldRedactValue }"
|
:class="{ 'ph-no-capture': shouldRedactValue }"
|
||||||
@update:modelValue="expressionUpdated"
|
@update:modelValue="expressionUpdated"
|
||||||
@modalOpenerClick="openExpressionEditorModal"
|
@modalOpenerClick="openExpressionEditorModal"
|
||||||
|
@ -366,6 +367,7 @@ import type {
|
||||||
IParameterLabel,
|
IParameterLabel,
|
||||||
EditorType,
|
EditorType,
|
||||||
CodeNodeEditorLanguage,
|
CodeNodeEditorLanguage,
|
||||||
|
IDataObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -411,6 +413,10 @@ export default defineComponent({
|
||||||
TextEdit,
|
TextEdit,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
additionalExpressionData: {
|
||||||
|
type: Object as PropType<IDataObject>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
isReadOnly: {
|
isReadOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
|
|
@ -66,8 +66,6 @@ import { mapStores } from 'pinia';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
type ParamRef = InstanceType<typeof ParameterInputWrapper>;
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'parameter-input-expanded',
|
name: 'parameter-input-expanded',
|
||||||
components: {
|
components: {
|
||||||
|
@ -116,6 +114,10 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.parameter.type === 'number') {
|
if (this.parameter.type === 'number') {
|
||||||
|
if (typeof this.value === 'string' && this.value.startsWith('=')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return typeof this.value !== 'number';
|
return typeof this.value !== 'number';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
:isForCredential="isForCredential"
|
:isForCredential="isForCredential"
|
||||||
:eventSource="eventSource"
|
:eventSource="eventSource"
|
||||||
:expressionEvaluated="expressionValueComputed"
|
:expressionEvaluated="expressionValueComputed"
|
||||||
|
:additionalExpressionData="resolvedAdditionalExpressionData"
|
||||||
:label="label"
|
:label="label"
|
||||||
:data-test-id="`parameter-input-${parameter.name}`"
|
:data-test-id="`parameter-input-${parameter.name}`"
|
||||||
:event-bus="eventBus"
|
:event-bus="eventBus"
|
||||||
|
@ -50,6 +51,7 @@ import { mapStores } from 'pinia';
|
||||||
import ParameterInput from '@/components/ParameterInput.vue';
|
import ParameterInput from '@/components/ParameterInput.vue';
|
||||||
import InputHint from '@/components/ParameterInputHint.vue';
|
import InputHint from '@/components/ParameterInputHint.vue';
|
||||||
import type {
|
import type {
|
||||||
|
IDataObject,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
INodePropertyMode,
|
INodePropertyMode,
|
||||||
IParameterLabel,
|
IParameterLabel,
|
||||||
|
@ -61,6 +63,8 @@ import type { INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
|
||||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||||
import { isValueExpression } from '@/utils';
|
import { isValueExpression } from '@/utils';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useEnvironmentsStore, useExternalSecretsStore } from '@/stores';
|
||||||
|
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
|
@ -72,6 +76,10 @@ export default defineComponent({
|
||||||
InputHint,
|
InputHint,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
additionalExpressionData: {
|
||||||
|
type: Object as PropType<IDataObject>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
isReadOnly: {
|
isReadOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
@ -127,7 +135,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNDVStore),
|
...mapStores(useNDVStore, useExternalSecretsStore, useEnvironmentsStore),
|
||||||
isValueExpression() {
|
isValueExpression() {
|
||||||
return isValueExpression(this.parameter, this.modelValue);
|
return isValueExpression(this.parameter, this.modelValue);
|
||||||
},
|
},
|
||||||
|
@ -183,6 +191,7 @@ export default defineComponent({
|
||||||
inputNodeName: this.ndvStore.ndvInputNodeName,
|
inputNodeName: this.ndvStore.ndvInputNodeName,
|
||||||
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
||||||
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
||||||
|
additionalKeys: this.resolvedAdditionalExpressionData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,6 +217,15 @@ export default defineComponent({
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
resolvedAdditionalExpressionData() {
|
||||||
|
return {
|
||||||
|
$vars: this.environmentsStore.variablesAsObject,
|
||||||
|
...(this.externalSecretsStore.isEnterpriseExternalSecretsEnabled && this.isForCredential
|
||||||
|
? { $secrets: this.externalSecretsStore.secretsAsObject }
|
||||||
|
: {}),
|
||||||
|
...this.additionalExpressionData,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onFocus() {
|
onFocus() {
|
||||||
|
|
|
@ -74,6 +74,17 @@ export default defineComponent({
|
||||||
available: this.canAccessApiSettings(),
|
available: this.canAccessApiSettings(),
|
||||||
activateOnRouteNames: [VIEWS.API_SETTINGS],
|
activateOnRouteNames: [VIEWS.API_SETTINGS],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'settings-external-secrets',
|
||||||
|
icon: 'vault',
|
||||||
|
label: this.$locale.baseText('settings.externalSecrets.title'),
|
||||||
|
position: 'top',
|
||||||
|
available: this.canAccessExternalSecrets(),
|
||||||
|
activateOnRouteNames: [
|
||||||
|
VIEWS.EXTERNAL_SECRETS_SETTINGS,
|
||||||
|
VIEWS.EXTERNAL_SECRETS_PROVIDER_SETTINGS,
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings-audit-logs',
|
id: 'settings-audit-logs',
|
||||||
icon: 'clipboard-list',
|
icon: 'clipboard-list',
|
||||||
|
@ -164,6 +175,9 @@ export default defineComponent({
|
||||||
canAccessUsageAndPlan(): boolean {
|
canAccessUsageAndPlan(): boolean {
|
||||||
return this.canUserAccessRouteByName(VIEWS.USAGE);
|
return this.canUserAccessRouteByName(VIEWS.USAGE);
|
||||||
},
|
},
|
||||||
|
canAccessExternalSecrets(): boolean {
|
||||||
|
return this.canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS);
|
||||||
|
},
|
||||||
canAccessSourceControl(): boolean {
|
canAccessSourceControl(): boolean {
|
||||||
return this.canUserAccessRouteByName(VIEWS.SOURCE_CONTROL);
|
return this.canUserAccessRouteByName(VIEWS.SOURCE_CONTROL);
|
||||||
},
|
},
|
||||||
|
@ -179,51 +193,43 @@ export default defineComponent({
|
||||||
openUpdatesPanel() {
|
openUpdatesPanel() {
|
||||||
this.uiStore.openModal(VERSIONS_MODAL_KEY);
|
this.uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||||
},
|
},
|
||||||
|
async navigateTo(routeName: (typeof VIEWS)[keyof typeof VIEWS]) {
|
||||||
|
if (this.$router.currentRoute.name !== routeName) {
|
||||||
|
await this.$router.push({ name: routeName });
|
||||||
|
}
|
||||||
|
},
|
||||||
async handleSelect(key: string) {
|
async handleSelect(key: string) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'settings-personal':
|
case 'settings-personal':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.PERSONAL_SETTINGS) {
|
await this.navigateTo(VIEWS.PERSONAL_SETTINGS);
|
||||||
await this.$router.push({ name: VIEWS.PERSONAL_SETTINGS });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-users':
|
case 'settings-users':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.USERS_SETTINGS) {
|
await this.navigateTo(VIEWS.USERS_SETTINGS);
|
||||||
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-api':
|
case 'settings-api':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.API_SETTINGS) {
|
await this.navigateTo(VIEWS.API_SETTINGS);
|
||||||
await this.$router.push({ name: VIEWS.API_SETTINGS });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-ldap':
|
case 'settings-ldap':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.LDAP_SETTINGS) {
|
await this.navigateTo(VIEWS.LDAP_SETTINGS);
|
||||||
void this.$router.push({ name: VIEWS.LDAP_SETTINGS });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-log-streaming':
|
case 'settings-log-streaming':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.LOG_STREAMING_SETTINGS) {
|
await this.navigateTo(VIEWS.LOG_STREAMING_SETTINGS);
|
||||||
void this.$router.push({ name: VIEWS.LOG_STREAMING_SETTINGS });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
|
case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
|
||||||
case 'logging':
|
case 'logging':
|
||||||
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
|
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
|
||||||
break;
|
break;
|
||||||
case 'settings-community-nodes':
|
case 'settings-community-nodes':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.COMMUNITY_NODES) {
|
await this.navigateTo(VIEWS.COMMUNITY_NODES);
|
||||||
await this.$router.push({ name: VIEWS.COMMUNITY_NODES });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-usage-and-plan':
|
case 'settings-usage-and-plan':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.USAGE) {
|
await this.navigateTo(VIEWS.USAGE);
|
||||||
void this.$router.push({ name: VIEWS.USAGE });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'settings-sso':
|
case 'settings-sso':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.SSO_SETTINGS) {
|
await this.navigateTo(VIEWS.SSO_SETTINGS);
|
||||||
void this.$router.push({ name: VIEWS.SSO_SETTINGS });
|
break;
|
||||||
}
|
case 'settings-external-secrets':
|
||||||
|
await this.navigateTo(VIEWS.EXTERNAL_SECRETS_SETTINGS);
|
||||||
break;
|
break;
|
||||||
case 'settings-source-control':
|
case 'settings-source-control':
|
||||||
if (this.$router.currentRoute.name !== VIEWS.SOURCE_CONTROL) {
|
if (this.$router.currentRoute.name !== VIEWS.SOURCE_CONTROL) {
|
||||||
|
|
|
@ -3,6 +3,7 @@ export * from './useCopyToClipboard';
|
||||||
export * from './useDebounce';
|
export * from './useDebounce';
|
||||||
export { default as useDeviceSupport } from './useDeviceSupport';
|
export { default as useDeviceSupport } from './useDeviceSupport';
|
||||||
export * from './useExternalHooks';
|
export * from './useExternalHooks';
|
||||||
|
export * from './useExternalSecretsProvider';
|
||||||
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
||||||
export * from './useHistoryHelper';
|
export * from './useHistoryHelper';
|
||||||
export * from './useI18n';
|
export * from './useI18n';
|
||||||
|
|
101
packages/editor-ui/src/composables/useExternalSecretsProvider.ts
Normal file
101
packages/editor-ui/src/composables/useExternalSecretsProvider.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import type {
|
||||||
|
ExternalSecretsProviderWithProperties,
|
||||||
|
ExternalSecretsProvider,
|
||||||
|
IUpdateInformation,
|
||||||
|
ExternalSecretsProviderData,
|
||||||
|
} from '@/Interface';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
export function useExternalSecretsProvider(
|
||||||
|
provider: Ref<ExternalSecretsProvider>,
|
||||||
|
providerData: Ref<ExternalSecretsProviderData>,
|
||||||
|
) {
|
||||||
|
const toast = useToast();
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
|
||||||
|
const initialConnectionState = ref<ExternalSecretsProviderWithProperties['state'] | undefined>(
|
||||||
|
'initializing',
|
||||||
|
);
|
||||||
|
const connectionState = computed(
|
||||||
|
() => externalSecretsStore.connectionState[provider.value?.name],
|
||||||
|
);
|
||||||
|
const setConnectionState = (state: ExternalSecretsProviderWithProperties['state']) => {
|
||||||
|
externalSecretsStore.setConnectionState(provider.value?.name, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedProviderData = computed(() => {
|
||||||
|
return Object.entries(providerData.value).reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
const property = provider.value?.properties?.find((property) => property.name === key);
|
||||||
|
if (shouldDisplayProperty(property)) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, IUpdateInformation['value']>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function shouldDisplayProperty(
|
||||||
|
property: ExternalSecretsProviderWithProperties['properties'][0],
|
||||||
|
): boolean {
|
||||||
|
let visible = true;
|
||||||
|
|
||||||
|
if (property.displayOptions?.show) {
|
||||||
|
visible =
|
||||||
|
visible &&
|
||||||
|
Object.entries(property.displayOptions.show).every(([key, value]) => {
|
||||||
|
return value?.includes(providerData.value[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.displayOptions?.hide) {
|
||||||
|
visible =
|
||||||
|
visible &&
|
||||||
|
!Object.entries(property.displayOptions.hide).every(([key, value]) => {
|
||||||
|
return value?.includes(providerData.value[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(options: { showError?: boolean } = { showError: true }) {
|
||||||
|
try {
|
||||||
|
const { testState } = await externalSecretsStore.testProviderConnection(
|
||||||
|
provider.value.name,
|
||||||
|
normalizedProviderData.value,
|
||||||
|
);
|
||||||
|
setConnectionState(testState);
|
||||||
|
|
||||||
|
return testState;
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionState('error');
|
||||||
|
|
||||||
|
if (options.showError) {
|
||||||
|
toast.showError(error, 'Error', error.response?.data?.data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'error';
|
||||||
|
} finally {
|
||||||
|
if (provider.value.connected && ['connected', 'error'].includes(connectionState.value)) {
|
||||||
|
externalSecretsStore.updateStoredProvider(provider.value.name, {
|
||||||
|
state: connectionState.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialConnectionState,
|
||||||
|
connectionState,
|
||||||
|
normalizedProviderData,
|
||||||
|
testConnection,
|
||||||
|
setConnectionState,
|
||||||
|
shouldDisplayProperty,
|
||||||
|
};
|
||||||
|
}
|
|
@ -50,6 +50,8 @@ export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
|
||||||
export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
|
export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
|
||||||
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
|
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
|
||||||
|
|
||||||
|
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
UPDATE: 'update',
|
UPDATE: 'update',
|
||||||
|
@ -375,6 +377,7 @@ export const enum VIEWS {
|
||||||
USAGE = 'Usage',
|
USAGE = 'Usage',
|
||||||
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
|
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
|
||||||
SSO_SETTINGS = 'SSoSettings',
|
SSO_SETTINGS = 'SSoSettings',
|
||||||
|
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
|
||||||
SAML_ONBOARDING = 'SamlOnboarding',
|
SAML_ONBOARDING = 'SamlOnboarding',
|
||||||
SOURCE_CONTROL = 'SourceControl',
|
SOURCE_CONTROL = 'SourceControl',
|
||||||
AUDIT_LOGS = 'AuditLogs',
|
AUDIT_LOGS = 'AuditLogs',
|
||||||
|
@ -447,6 +450,7 @@ export const enum EnterpriseEditionFeature {
|
||||||
Variables = 'variables',
|
Variables = 'variables',
|
||||||
Saml = 'saml',
|
Saml = 'saml',
|
||||||
SourceControl = 'sourceControl',
|
SourceControl = 'sourceControl',
|
||||||
|
ExternalSecrets = 'externalSecrets',
|
||||||
AuditLogs = 'auditLogs',
|
AuditLogs = 'auditLogs',
|
||||||
DebugInEditor = 'debugInEditor',
|
DebugInEditor = 'debugInEditor',
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { defineComponent } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
|
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { Expression, ExpressionExtensions } from 'n8n-workflow';
|
import { Expression, ExpressionExtensions } from 'n8n-workflow';
|
||||||
import { ensureSyntaxTree } from '@codemirror/language';
|
import { ensureSyntaxTree } from '@codemirror/language';
|
||||||
|
|
||||||
|
@ -19,6 +20,10 @@ export const expressionManager = defineComponent({
|
||||||
targetItem: {
|
targetItem: {
|
||||||
type: Object as PropType<TargetItem | null>,
|
type: Object as PropType<TargetItem | null>,
|
||||||
},
|
},
|
||||||
|
additionalData: {
|
||||||
|
type: Object as PropType<IDataObject>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -194,7 +199,7 @@ export const expressionManager = defineComponent({
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
if (!ndvStore.activeNode) {
|
if (!ndvStore.activeNode) {
|
||||||
// e.g. credential modal
|
// e.g. credential modal
|
||||||
result.resolved = Expression.resolveWithoutWorkflow(resolvable);
|
result.resolved = Expression.resolveWithoutWorkflow(resolvable, this.additionalData);
|
||||||
} else {
|
} else {
|
||||||
let opts;
|
let opts;
|
||||||
if (ndvStore.isInputParentOfActiveNode) {
|
if (ndvStore.isInputParentOfActiveNode) {
|
||||||
|
@ -203,6 +208,7 @@ export const expressionManager = defineComponent({
|
||||||
inputNodeName: this.ndvStore.ndvInputNodeName,
|
inputNodeName: this.ndvStore.ndvInputNodeName,
|
||||||
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
||||||
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
||||||
|
additionalKeys: this.additionalData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
result.resolved = this.resolveExpression('=' + resolvable, undefined, opts);
|
result.resolved = this.resolveExpression('=' + resolvable, undefined, opts);
|
||||||
|
|
|
@ -71,6 +71,7 @@ export function resolveParameter(
|
||||||
inputNodeName?: string;
|
inputNodeName?: string;
|
||||||
inputRunIndex?: number;
|
inputRunIndex?: number;
|
||||||
inputBranchIndex?: number;
|
inputBranchIndex?: number;
|
||||||
|
additionalKeys?: IWorkflowDataProxyAdditionalKeys;
|
||||||
} = {},
|
} = {},
|
||||||
): IDataObject | null {
|
): IDataObject | null {
|
||||||
let itemIndex = opts?.targetItem?.itemIndex || 0;
|
let itemIndex = opts?.targetItem?.itemIndex || 0;
|
||||||
|
@ -146,6 +147,8 @@ export function resolveParameter(
|
||||||
// deprecated
|
// deprecated
|
||||||
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
|
|
||||||
|
...opts.additionalKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
|
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
|
||||||
|
@ -646,6 +649,7 @@ export const workflowHelpers = defineComponent({
|
||||||
inputRunIndex?: number;
|
inputRunIndex?: number;
|
||||||
inputBranchIndex?: number;
|
inputBranchIndex?: number;
|
||||||
c?: number;
|
c?: number;
|
||||||
|
additionalKeys?: IWorkflowDataProxyAdditionalKeys;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const parameters = {
|
const parameters = {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import * as workflowHelpers from '@/mixins/workflowHelpers';
|
import * as workflowHelpers from '@/mixins/workflowHelpers';
|
||||||
|
@ -17,12 +17,38 @@ import type { CompletionSource, CompletionResult } from '@codemirror/autocomplet
|
||||||
import { CompletionContext } from '@codemirror/autocomplete';
|
import { CompletionContext } from '@codemirror/autocomplete';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
|
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
|
||||||
|
import { setupServer } from '@/__tests__/server';
|
||||||
|
|
||||||
|
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
|
||||||
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
|
|
||||||
|
let server: ReturnType<typeof setupServer>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = setupServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
|
||||||
|
externalSecretsStore = useExternalSecretsStore();
|
||||||
|
uiStore = useUIStore();
|
||||||
|
settingsStore = useSettingsStore();
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createTestingPinia());
|
|
||||||
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
|
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
|
||||||
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
|
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
|
||||||
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
|
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
|
||||||
|
|
||||||
|
await settingsStore.getSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('No completions', () => {
|
describe('No completions', () => {
|
||||||
|
@ -264,6 +290,62 @@ describe('Resolution-based completions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('secrets', () => {
|
||||||
|
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
|
||||||
|
const { $input, $ } = mockProxy;
|
||||||
|
|
||||||
|
test('should return completions for: {{ $secrets.| }}', () => {
|
||||||
|
const provider = 'infisical';
|
||||||
|
const secrets = ['SECRET'];
|
||||||
|
|
||||||
|
resolveParameterSpy.mockReturnValue($input);
|
||||||
|
|
||||||
|
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
|
||||||
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
|
||||||
|
externalSecretsStore.state.secrets = {
|
||||||
|
[provider]: secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = completions('{{ $secrets.| }}');
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
info: expect.any(Function),
|
||||||
|
label: provider,
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return completions for: {{ $secrets.provider.| }}', () => {
|
||||||
|
const provider = 'infisical';
|
||||||
|
const secrets = ['SECRET1', 'SECRET2'];
|
||||||
|
|
||||||
|
resolveParameterSpy.mockReturnValue($input);
|
||||||
|
|
||||||
|
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
|
||||||
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
|
||||||
|
externalSecretsStore.state.secrets = {
|
||||||
|
[provider]: secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = completions(`{{ $secrets.${provider}.| }}`);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
info: expect.any(Function),
|
||||||
|
label: secrets[0],
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: expect.any(Function),
|
||||||
|
label: secrets[1],
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('references', () => {
|
describe('references', () => {
|
||||||
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
|
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
|
||||||
const { $input, $ } = mockProxy;
|
const { $input, $ } = mockProxy;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow';
|
import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow';
|
||||||
|
import { Expression } from 'n8n-workflow';
|
||||||
import { ExpressionExtensions, NativeMethods } from 'n8n-workflow';
|
import { ExpressionExtensions, NativeMethods } from 'n8n-workflow';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { i18n } from '@/plugins/i18n';
|
import { i18n } from '@/plugins/i18n';
|
||||||
|
@ -13,6 +14,7 @@ import {
|
||||||
splitBaseTail,
|
splitBaseTail,
|
||||||
isPseudoParam,
|
isPseudoParam,
|
||||||
stripExcessParens,
|
stripExcessParens,
|
||||||
|
isCredentialsModalOpen,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
import type { AutocompleteOptionType, ExtensionTypeName, FnToDoc, Resolved } from './types';
|
import type { AutocompleteOptionType, ExtensionTypeName, FnToDoc, Resolved } from './types';
|
||||||
|
@ -20,7 +22,7 @@ import { sanitizeHtml } from '@/utils';
|
||||||
import { isFunctionOption } from './typeGuards';
|
import { isFunctionOption } from './typeGuards';
|
||||||
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
|
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
|
||||||
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
|
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
|
||||||
import { useEnvironmentsStore } from '@/stores';
|
import { useEnvironmentsStore, useExternalSecretsStore } from '@/stores';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution-based completions offered according to datatype.
|
* Resolution-based completions offered according to datatype.
|
||||||
|
@ -37,12 +39,18 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
|
||||||
|
|
||||||
let options: Completion[] = [];
|
let options: Completion[] = [];
|
||||||
|
|
||||||
|
const isCredential = isCredentialsModalOpen();
|
||||||
|
|
||||||
if (base === 'DateTime') {
|
if (base === 'DateTime') {
|
||||||
options = luxonStaticOptions().map(stripExcessParens(context));
|
options = luxonStaticOptions().map(stripExcessParens(context));
|
||||||
} else if (base === 'Object') {
|
} else if (base === 'Object') {
|
||||||
options = objectGlobalOptions().map(stripExcessParens(context));
|
options = objectGlobalOptions().map(stripExcessParens(context));
|
||||||
} else if (base === '$vars') {
|
} else if (base === '$vars') {
|
||||||
options = variablesOptions();
|
options = variablesOptions();
|
||||||
|
} else if (/\$secrets\./.test(base) && isCredential) {
|
||||||
|
options = secretOptions(base).map(stripExcessParens(context));
|
||||||
|
} else if (base === '$secrets' && isCredential) {
|
||||||
|
options = secretProvidersOptions();
|
||||||
} else {
|
} else {
|
||||||
let resolved: Resolved;
|
let resolved: Resolved;
|
||||||
|
|
||||||
|
@ -351,6 +359,54 @@ export const variablesOptions = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const secretOptions = (base: string) => {
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
let resolved: Resolved;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolved = Expression.resolveWithoutWorkflow(`{{ ${base} }}`, {
|
||||||
|
$secrets: externalSecretsStore.secretsAsObject,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved === null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof resolved !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(resolved).map(([secret, value]) =>
|
||||||
|
createCompletionOption('Object', secret, 'keyword', {
|
||||||
|
doc: {
|
||||||
|
name: secret,
|
||||||
|
returnType: typeof value,
|
||||||
|
description: i18n.baseText('codeNodeEditor.completer.$secrets.provider.varName'),
|
||||||
|
docURL: i18n.baseText('settings.externalSecrets.docs'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secretProvidersOptions = () => {
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
|
||||||
|
return Object.keys(externalSecretsStore.secretsAsObject).map((provider) =>
|
||||||
|
createCompletionOption('Object', provider, 'keyword', {
|
||||||
|
doc: {
|
||||||
|
name: provider,
|
||||||
|
returnType: 'object',
|
||||||
|
description: i18n.baseText('codeNodeEditor.completer.$secrets.provider'),
|
||||||
|
docURL: i18n.baseText('settings.externalSecrets.docs'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Methods and fields defined on a Luxon `DateTime` class instance.
|
* Methods and fields defined on a Luxon `DateTime` class instance.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -7,8 +7,10 @@ import {
|
||||||
prefixMatch,
|
prefixMatch,
|
||||||
stripExcessParens,
|
stripExcessParens,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
|
isCredentialsModalOpen,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import { useExternalSecretsStore } from '@/stores';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completions offered at the dollar position: `$|`
|
* Completions offered at the dollar position: `$|`
|
||||||
|
@ -47,7 +49,24 @@ export function dollarOptions() {
|
||||||
const SKIP = new Set();
|
const SKIP = new Set();
|
||||||
const DOLLAR_FUNCTIONS = ['$jmespath'];
|
const DOLLAR_FUNCTIONS = ['$jmespath'];
|
||||||
|
|
||||||
if (!hasActiveNode()) return []; // e.g. credential modal
|
if (isCredentialsModalOpen()) {
|
||||||
|
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: '$secrets',
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '$vars',
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasActiveNode()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (receivesNoBinaryData()) SKIP.add('$binary');
|
if (receivesNoBinaryData()) SKIP.add('$binary');
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
|
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
|
||||||
import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants';
|
import { CREDENTIAL_EDIT_MODAL_KEY, SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { resolveParameter } from '@/mixins/workflowHelpers';
|
import { resolveParameter } from '@/mixins/workflowHelpers';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
|
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
|
||||||
|
|
||||||
// String literal expression is everything enclosed in single, double or tick quotes following a dot
|
// String literal expression is everything enclosed in single, double or tick quotes following a dot
|
||||||
|
@ -125,6 +126,8 @@ export function hasNoParams(toResolve: string) {
|
||||||
// state-based utils
|
// state-based utils
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
|
export const isCredentialsModalOpen = () => useUIStore().modals[CREDENTIAL_EDIT_MODAL_KEY].open;
|
||||||
|
|
||||||
export const hasActiveNode = () => useNDVStore().activeNode?.name !== undefined;
|
export const hasActiveNode = () => useNDVStore().activeNode?.name !== undefined;
|
||||||
|
|
||||||
export const isSplitInBatchesAbsent = () =>
|
export const isSplitInBatchesAbsent = () =>
|
||||||
|
|
|
@ -156,6 +156,9 @@
|
||||||
"codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)",
|
"codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)",
|
||||||
"codeNodeEditor.completer.$vars": "The variables defined in your instance",
|
"codeNodeEditor.completer.$vars": "The variables defined in your instance",
|
||||||
"codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.",
|
"codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.",
|
||||||
|
"codeNodeEditor.completer.$secrets": "The external secrets connected to your instance",
|
||||||
|
"codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.",
|
||||||
|
"codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.",
|
||||||
"codeNodeEditor.completer.$workflow": "Information about the workflow",
|
"codeNodeEditor.completer.$workflow": "Information about the workflow",
|
||||||
"codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)",
|
"codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)",
|
||||||
"codeNodeEditor.completer.$workflow.id": "The ID of the workflow",
|
"codeNodeEditor.completer.$workflow.id": "The ID of the workflow",
|
||||||
|
@ -340,6 +343,8 @@
|
||||||
"credentialEdit.credentialConfig.authTypeSelectorLabel": "Connect using",
|
"credentialEdit.credentialConfig.authTypeSelectorLabel": "Connect using",
|
||||||
"credentialEdit.credentialConfig.authTypeSelectorTooltip": "The authentication method to use for the connection",
|
"credentialEdit.credentialConfig.authTypeSelectorTooltip": "The authentication method to use for the connection",
|
||||||
"credentialEdit.credentialConfig.recommendedAuthTypeSuffix": "(recommended)",
|
"credentialEdit.credentialConfig.recommendedAuthTypeSuffix": "(recommended)",
|
||||||
|
"credentialEdit.credentialConfig.externalSecrets": "Enterprise plan users can pull in credentials from external vaults.",
|
||||||
|
"credentialEdit.credentialConfig.externalSecrets.moreInfo": "More info",
|
||||||
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText": "Close",
|
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText": "Close",
|
||||||
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText": "Keep Editing",
|
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText": "Keep Editing",
|
||||||
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline": "Close without saving?",
|
"credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline": "Close without saving?",
|
||||||
|
@ -1378,6 +1383,43 @@
|
||||||
"settings.usageAndPlan.license.activation.success.message": "Your {name} {type} has been successfully activated.",
|
"settings.usageAndPlan.license.activation.success.message": "Your {name} {type} has been successfully activated.",
|
||||||
"settings.usageAndPlan.desktop.title": "Upgrade to n8n Cloud for the full experience",
|
"settings.usageAndPlan.desktop.title": "Upgrade to n8n Cloud for the full experience",
|
||||||
"settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you don’t need to leave this app open all the time for your workflows to run.",
|
"settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you don’t need to leave this app open all the time for your workflows to run.",
|
||||||
|
"settings.externalSecrets.title": "External Secrets",
|
||||||
|
"settings.externalSecrets.info": "Connect external secrets tools for centralized credentials management across environments, and to enhance system security.",
|
||||||
|
"settings.externalSecrets.info.link": "More info",
|
||||||
|
"settings.externalSecrets.actionBox.title": "Available on the Enterprise plan",
|
||||||
|
"settings.externalSecrets.actionBox.description": "Connect external secrets tools for centralized credentials management across instances. {link}",
|
||||||
|
"settings.externalSecrets.actionBox.description.link": "More info",
|
||||||
|
"settings.externalSecrets.actionBox.buttonText": "See plans",
|
||||||
|
"settings.externalSecrets.card.setUp": "Set Up",
|
||||||
|
"settings.externalSecrets.card.secretsCount": "{count} secrets",
|
||||||
|
"settings.externalSecrets.card.connectedAt": "Connected {date}",
|
||||||
|
"settings.externalSecrets.card.connected": "Enabled",
|
||||||
|
"settings.externalSecrets.card.disconnected": "Disabled",
|
||||||
|
"settings.externalSecrets.card.actionDropdown.setup": "Edit connection",
|
||||||
|
"settings.externalSecrets.card.actionDropdown.reload": "Reload secrets",
|
||||||
|
"settings.externalSecrets.card.reload.success.title": "Reloaded successfully",
|
||||||
|
"settings.externalSecrets.card.reload.success.description": "All secrets have been reloaded from {provider}.",
|
||||||
|
"settings.externalSecrets.provider.title": "Commit and push changes",
|
||||||
|
"settings.externalSecrets.provider.description": "Select the files you want to stage in your commit and add a commit message. ",
|
||||||
|
"settings.externalSecrets.provider.buttons.cancel": "Cancel",
|
||||||
|
"settings.externalSecrets.provider.buttons.save": "Save",
|
||||||
|
"settings.externalSecrets.provider.buttons.saving": "Saving",
|
||||||
|
"settings.externalSecrets.card.connectedSwitch.title": "Enable {provider}",
|
||||||
|
"settings.externalSecrets.provider.save.success.title": "Provider settings saved successfully",
|
||||||
|
"settings.externalSecrets.provider.connected.success.title": "Provider connected successfully",
|
||||||
|
"settings.externalSecrets.provider.disconnected.success.title": "Provider disconnected successfully",
|
||||||
|
"settings.externalSecrets.provider.testConnection.success.connected": "Service enabled, {count} secrets available on {provider}.",
|
||||||
|
"settings.externalSecrets.provider.testConnection.success.connected.usage": "Use secrets in credentials by setting a parameter to an expression and typing: {code}. ",
|
||||||
|
"settings.externalSecrets.provider.testConnection.success.connected.docs": "More info",
|
||||||
|
"settings.externalSecrets.provider.testConnection.success": "Connection to {provider} executed successfully. Enable the service to use the secrets in credentials.",
|
||||||
|
"settings.externalSecrets.provider.testConnection.error.connected": "Connection unsuccessful, please check your {provider} settings",
|
||||||
|
"settings.externalSecrets.provider.testConnection.error": "Connection unsuccessful, please check your {provider} settings",
|
||||||
|
"settings.externalSecrets.provider.closeWithoutSaving.title": "Close without saving?",
|
||||||
|
"settings.externalSecrets.provider.closeWithoutSaving.description": "Are you sure you want to throw away the changes you made to the {provider} settings?",
|
||||||
|
"settings.externalSecrets.provider.closeWithoutSaving.cancel": "Close",
|
||||||
|
"settings.externalSecrets.provider.closeWithoutSaving.confirm": "Keep editing",
|
||||||
|
"settings.externalSecrets.docs": "https://docs.n8n.io/external-secrets/",
|
||||||
|
"settings.externalSecrets.docs.use": "https://docs.n8n.io/external-secrets/#use-secrets-in-n8n-credentials",
|
||||||
"settings.sourceControl.title": "Environments",
|
"settings.sourceControl.title": "Environments",
|
||||||
"settings.sourceControl.actionBox.title": "Available on the Enterprise plan",
|
"settings.sourceControl.actionBox.title": "Available on the Enterprise plan",
|
||||||
"settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.",
|
"settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.",
|
||||||
|
|
|
@ -12,6 +12,18 @@ export const faVariable: IconDefinition = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const faVault: IconDefinition = {
|
||||||
|
prefix: 'fas' as IconPrefix,
|
||||||
|
iconName: 'vault' as IconName,
|
||||||
|
icon: [
|
||||||
|
576,
|
||||||
|
512,
|
||||||
|
[],
|
||||||
|
'e006',
|
||||||
|
'M64 0C28.7 0 0 28.7 0 64v352c0 35.3 28.7 64 64 64h16l16 32h64l16-32h224l16 32h64l16-32h16c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zm160 320a80 80 0 1 0 0-160a80 80 0 1 0 0 160zm0-240a160 160 0 1 1 0 320a160 160 0 1 1 0-320zm256 141.3V336c0 8.8-7.2 16-16 16s-16-7.2-16-16V221.3c-18.6-6.6-32-24.4-32-45.3c0-26.5 21.5-48 48-48s48 21.5 48 48c0 20.9-13.4 38.7-32 45.3z',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const faXmark: IconDefinition = {
|
export const faXmark: IconDefinition = {
|
||||||
prefix: 'fas' as IconPrefix,
|
prefix: 'fas' as IconPrefix,
|
||||||
iconName: 'xmark' as IconName,
|
iconName: 'xmark' as IconName,
|
||||||
|
|
|
@ -135,7 +135,7 @@ import {
|
||||||
faGem,
|
faGem,
|
||||||
faDownload,
|
faDownload,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faVariable, faXmark } from './custom';
|
import { faVariable, faXmark, faVault } from './custom';
|
||||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
|
||||||
|
@ -274,6 +274,7 @@ export const FontAwesomePlugin: Plugin<{}> = {
|
||||||
addIcon(faUserFriends);
|
addIcon(faUserFriends);
|
||||||
addIcon(faUsers);
|
addIcon(faUsers);
|
||||||
addIcon(faVariable);
|
addIcon(faVariable);
|
||||||
|
addIcon(faVault);
|
||||||
addIcon(faVideo);
|
addIcon(faVideo);
|
||||||
addIcon(faTree);
|
addIcon(faTree);
|
||||||
addIcon(faUserLock);
|
addIcon(faUserLock);
|
||||||
|
|
|
@ -38,6 +38,7 @@ import SettingsSso from './views/SettingsSso.vue';
|
||||||
import SignoutView from '@/views/SignoutView.vue';
|
import SignoutView from '@/views/SignoutView.vue';
|
||||||
import SamlOnboarding from '@/views/SamlOnboarding.vue';
|
import SamlOnboarding from '@/views/SamlOnboarding.vue';
|
||||||
import SettingsSourceControl from './views/SettingsSourceControl.vue';
|
import SettingsSourceControl from './views/SettingsSourceControl.vue';
|
||||||
|
import SettingsExternalSecrets from './views/SettingsExternalSecrets.vue';
|
||||||
import SettingsAuditLogs from './views/SettingsAuditLogs.vue';
|
import SettingsAuditLogs from './views/SettingsAuditLogs.vue';
|
||||||
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
||||||
|
|
||||||
|
@ -597,6 +598,28 @@ export const routes = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'external-secrets',
|
||||||
|
name: VIEWS.EXTERNAL_SECRETS_SETTINGS,
|
||||||
|
components: {
|
||||||
|
settingsView: SettingsExternalSecrets,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
telemetry: {
|
||||||
|
pageCategory: 'settings',
|
||||||
|
getProperties(route: Route) {
|
||||||
|
return {
|
||||||
|
feature: 'external-secrets',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
allow: {
|
||||||
|
role: [ROLE.Owner],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'sso',
|
path: 'sso',
|
||||||
name: VIEWS.SSO_SETTINGS,
|
name: VIEWS.SSO_SETTINGS,
|
||||||
|
|
164
packages/editor-ui/src/stores/externalSecrets.ee.store.ts
Normal file
164
packages/editor-ui/src/stores/externalSecrets.ee.store.ts
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { EnterpriseEditionFeature } from '@/constants';
|
||||||
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import * as externalSecretsApi from '@/api/externalSecrets.ee';
|
||||||
|
import { connectProvider } from '@/api/externalSecrets.ee';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
|
||||||
|
export const useExternalSecretsStore = defineStore('externalSecrets', () => {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
providers: [] as ExternalSecretsProvider[],
|
||||||
|
secrets: {} as Record<string, string[]>,
|
||||||
|
connectionState: {} as Record<string, ExternalSecretsProvider['state']>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEnterpriseExternalSecretsEnabled = computed(() =>
|
||||||
|
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets),
|
||||||
|
);
|
||||||
|
|
||||||
|
const secrets = computed(() => state.secrets);
|
||||||
|
const providers = computed(() => state.providers);
|
||||||
|
const connectionState = computed(() => state.connectionState);
|
||||||
|
|
||||||
|
const secretsAsObject = computed(() => {
|
||||||
|
return Object.keys(secrets.value).reduce<Record<string, Record<string, object | string>>>(
|
||||||
|
(providerAcc, provider) => {
|
||||||
|
providerAcc[provider] = secrets.value[provider]?.reduce<Record<string, object | string>>(
|
||||||
|
(secretAcc, secret) => {
|
||||||
|
const splitSecret = secret.split('.');
|
||||||
|
if (splitSecret.length === 1) {
|
||||||
|
secretAcc[secret] = '*********';
|
||||||
|
return secretAcc;
|
||||||
|
}
|
||||||
|
const obj = (secretAcc[splitSecret[0]] ?? {}) as object;
|
||||||
|
let acc: any = obj;
|
||||||
|
for (let i = 1; i < splitSecret.length; i++) {
|
||||||
|
const key = splitSecret[i];
|
||||||
|
// Actual value key
|
||||||
|
if (i === splitSecret.length - 1) {
|
||||||
|
acc[key] = '*********';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(key in acc)) {
|
||||||
|
acc[key] = {};
|
||||||
|
}
|
||||||
|
acc = acc[key];
|
||||||
|
}
|
||||||
|
secretAcc[splitSecret[0]] = obj;
|
||||||
|
return secretAcc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return providerAcc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchAllSecrets() {
|
||||||
|
if (usersStore.isInstanceOwner) {
|
||||||
|
try {
|
||||||
|
state.secrets = await externalSecretsApi.getExternalSecrets(rootStore.getRestApiContext);
|
||||||
|
} catch (error) {
|
||||||
|
state.secrets = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadProvider(id: string) {
|
||||||
|
const { updated } = await externalSecretsApi.reloadProvider(rootStore.getRestApiContext, id);
|
||||||
|
if (updated) {
|
||||||
|
await fetchAllSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProviders() {
|
||||||
|
state.providers = await externalSecretsApi.getExternalSecretsProviders(
|
||||||
|
rootStore.getRestApiContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProviderConnection(id: string, data: ExternalSecretsProvider['data']) {
|
||||||
|
return externalSecretsApi.testExternalSecretsProviderConnection(
|
||||||
|
rootStore.getRestApiContext,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProvider(id: string) {
|
||||||
|
const provider = await externalSecretsApi.getExternalSecretsProvider(
|
||||||
|
rootStore.getRestApiContext,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingProviderIndex = state.providers.findIndex((p) => p.name === id);
|
||||||
|
if (existingProviderIndex !== -1) {
|
||||||
|
state.providers.splice(existingProviderIndex, 1, provider);
|
||||||
|
} else {
|
||||||
|
state.providers.push(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStoredProvider(id: string, data: Partial<ExternalSecretsProvider>) {
|
||||||
|
const providerIndex = state.providers.findIndex((p) => p.name === id);
|
||||||
|
state.providers = [
|
||||||
|
...state.providers.slice(0, providerIndex),
|
||||||
|
{
|
||||||
|
...state.providers[providerIndex],
|
||||||
|
...data,
|
||||||
|
data: {
|
||||||
|
...state.providers[providerIndex].data,
|
||||||
|
...data.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...state.providers.slice(providerIndex + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProviderConnected(id: string, value: boolean) {
|
||||||
|
await connectProvider(rootStore.getRestApiContext, id, value);
|
||||||
|
await fetchAllSecrets();
|
||||||
|
updateStoredProvider(id, { connected: value, state: value ? 'connected' : 'initializing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProvider(id: string, { data }: Partial<ExternalSecretsProvider>) {
|
||||||
|
await externalSecretsApi.updateProvider(rootStore.getRestApiContext, id, data);
|
||||||
|
await fetchAllSecrets();
|
||||||
|
updateStoredProvider(id, { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectionState(id: string, connectionState: ExternalSecretsProvider['state']) {
|
||||||
|
state.connectionState[id] = connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
providers,
|
||||||
|
secrets,
|
||||||
|
connectionState,
|
||||||
|
secretsAsObject,
|
||||||
|
isEnterpriseExternalSecretsEnabled,
|
||||||
|
fetchAllSecrets,
|
||||||
|
getProvider,
|
||||||
|
getProviders,
|
||||||
|
testProviderConnection,
|
||||||
|
updateProvider,
|
||||||
|
updateStoredProvider,
|
||||||
|
updateProviderConnected,
|
||||||
|
reloadProvider,
|
||||||
|
setConnectionState,
|
||||||
|
};
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ export * from './canvas.store';
|
||||||
export * from './communityNodes.store';
|
export * from './communityNodes.store';
|
||||||
export * from './credentials.store';
|
export * from './credentials.store';
|
||||||
export * from './environments.ee.store';
|
export * from './environments.ee.store';
|
||||||
|
export * from './externalSecrets.ee.store';
|
||||||
export * from './history.store';
|
export * from './history.store';
|
||||||
export * from './logStreaming.store';
|
export * from './logStreaming.store';
|
||||||
export * from './n8nRoot.store';
|
export * from './n8nRoot.store';
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
|
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||||
DEBUG_PAYWALL_MODAL_KEY,
|
DEBUG_PAYWALL_MODAL_KEY,
|
||||||
|
@ -143,6 +144,9 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
|
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
},
|
},
|
||||||
|
[EXTERNAL_SECRETS_PROVIDER_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
[DEBUG_PAYWALL_MODAL_KEY]: {
|
[DEBUG_PAYWALL_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
},
|
},
|
||||||
|
|
12
packages/editor-ui/src/utils/expressions.ts
Normal file
12
packages/editor-ui/src/utils/expressions.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { ExpressionParser } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const isExpression = (expr: string) => expr.startsWith('=');
|
||||||
|
|
||||||
|
export const isTestableExpression = (expr: string) => {
|
||||||
|
return ExpressionParser.splitExpression(expr).every((c) => {
|
||||||
|
if (c.type === 'text') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /\$secrets(\.[a-zA-Z0-9_]+)+$/.test(c.text.trim());
|
||||||
|
});
|
||||||
|
};
|
|
@ -9,3 +9,4 @@ export * from './typeGuards';
|
||||||
export * from './typesUtils';
|
export * from './typesUtils';
|
||||||
export * from './userUtils';
|
export * from './userUtils';
|
||||||
export * from './sourceControlUtils';
|
export * from './sourceControlUtils';
|
||||||
|
export * from './expressions';
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
import { useExternalSecretsStore } from '@/stores';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
|
||||||
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
||||||
|
@ -84,6 +85,7 @@ export default defineComponent({
|
||||||
useUIStore,
|
useUIStore,
|
||||||
useUsersStore,
|
useUsersStore,
|
||||||
useSourceControlStore,
|
useSourceControlStore,
|
||||||
|
useExternalSecretsStore,
|
||||||
),
|
),
|
||||||
allCredentials(): ICredentialsResponse[] {
|
allCredentials(): ICredentialsResponse[] {
|
||||||
return this.credentialsStore.allCredentials;
|
return this.credentialsStore.allCredentials;
|
||||||
|
@ -107,6 +109,7 @@ export default defineComponent({
|
||||||
const loadPromises = [
|
const loadPromises = [
|
||||||
this.credentialsStore.fetchAllCredentials(),
|
this.credentialsStore.fetchAllCredentials(),
|
||||||
this.credentialsStore.fetchCredentialTypes(false),
|
this.credentialsStore.fetchCredentialTypes(false),
|
||||||
|
this.externalSecretsStore.fetchAllSecrets(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
||||||
|
|
|
@ -288,6 +288,7 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUIStore,
|
useUIStore,
|
||||||
useHistoryStore,
|
useHistoryStore,
|
||||||
|
useExternalSecretsStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||||
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
|
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
|
||||||
|
@ -496,6 +497,7 @@ export default defineComponent({
|
||||||
useEnvironmentsStore,
|
useEnvironmentsStore,
|
||||||
useWorkflowsEEStore,
|
useWorkflowsEEStore,
|
||||||
useHistoryStore,
|
useHistoryStore,
|
||||||
|
useExternalSecretsStore,
|
||||||
),
|
),
|
||||||
nativelyNumberSuffixedDefaults(): string[] {
|
nativelyNumberSuffixedDefaults(): string[] {
|
||||||
return this.nodeTypesStore.nativelyNumberSuffixedDefaults;
|
return this.nodeTypesStore.nativelyNumberSuffixedDefaults;
|
||||||
|
@ -3603,6 +3605,9 @@ export default defineComponent({
|
||||||
async loadVariables(): Promise<void> {
|
async loadVariables(): Promise<void> {
|
||||||
await this.environmentsStore.fetchAllVariables();
|
await this.environmentsStore.fetchAllVariables();
|
||||||
},
|
},
|
||||||
|
async loadSecrets(): Promise<void> {
|
||||||
|
await this.externalSecretsStore.fetchAllSecrets();
|
||||||
|
},
|
||||||
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||||
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
|
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
|
||||||
|
|
||||||
|
@ -3909,6 +3914,7 @@ export default defineComponent({
|
||||||
this.loadCredentials(),
|
this.loadCredentials(),
|
||||||
this.loadCredentialTypes(),
|
this.loadCredentialTypes(),
|
||||||
this.loadVariables(),
|
this.loadVariables(),
|
||||||
|
this.loadSecrets(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
||||||
|
|
75
packages/editor-ui/src/views/SettingsExternalSecrets.vue
Normal file
75
packages/editor-ui/src/views/SettingsExternalSecrets.vue
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useI18n, useMessage, useToast } from '@/composables';
|
||||||
|
import { useExternalSecretsStore } from '@/stores';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import ExternalSecretsProviderCard from '@/components/ExternalSecretsProviderCard.ee.vue';
|
||||||
|
import type { ExternalSecretsProvider } from '@/Interface';
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const sortedProviders = computed(() => {
|
||||||
|
return ([...externalSecretsStore.providers] as ExternalSecretsProvider[]).sort((a, b) => {
|
||||||
|
return b.name.localeCompare(a.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
void externalSecretsStore.fetchAllSecrets();
|
||||||
|
void externalSecretsStore.getProviders();
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
uiStore.goToUpgrade('external-secrets', 'upgrade-external-secrets');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pb-3xl">
|
||||||
|
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.externalSecrets.title') }}</n8n-heading>
|
||||||
|
<div
|
||||||
|
v-if="externalSecretsStore.isEnterpriseExternalSecretsEnabled"
|
||||||
|
data-test-id="external-secrets-content-licensed"
|
||||||
|
>
|
||||||
|
<n8n-callout theme="secondary" class="mt-2xl mb-l">
|
||||||
|
{{ i18n.baseText('settings.externalSecrets.info') }}
|
||||||
|
<a :href="i18n.baseText('settings.externalSecrets.docs')" target="_blank">
|
||||||
|
{{ i18n.baseText('settings.externalSecrets.info.link') }}
|
||||||
|
</a>
|
||||||
|
</n8n-callout>
|
||||||
|
<ExternalSecretsProviderCard
|
||||||
|
v-for="provider in sortedProviders"
|
||||||
|
:key="provider.name"
|
||||||
|
:provider="provider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n8n-action-box
|
||||||
|
v-else
|
||||||
|
class="mt-2xl mb-l"
|
||||||
|
data-test-id="external-secrets-content-unlicensed"
|
||||||
|
:buttonText="i18n.baseText('settings.externalSecrets.actionBox.buttonText')"
|
||||||
|
@click="goToUpgrade"
|
||||||
|
>
|
||||||
|
<template #heading>
|
||||||
|
<span>{{ i18n.baseText('settings.externalSecrets.actionBox.title') }}</span>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<i18n-t keypath="settings.externalSecrets.actionBox.description">
|
||||||
|
<template #link>
|
||||||
|
<a :href="i18n.baseText('settings.externalSecrets.docs')" target="_blank">
|
||||||
|
{{ i18n.baseText('settings.externalSecrets.actionBox.description.link') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</template>
|
||||||
|
</n8n-action-box>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { merge } from 'lodash-es';
|
||||||
|
import { EnterpriseEditionFeature, STORES } from '@/constants';
|
||||||
|
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||||
|
import SettingsExternalSecrets from '@/views/SettingsExternalSecrets.vue';
|
||||||
|
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { useSettingsStore } from '@/stores';
|
||||||
|
import { setupServer } from '@/__tests__/server';
|
||||||
|
|
||||||
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
|
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
|
||||||
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
|
let server: ReturnType<typeof setupServer>;
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(SettingsExternalSecrets);
|
||||||
|
|
||||||
|
describe('SettingsExternalSecrets', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
server = setupServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
pinia = createTestingPinia({
|
||||||
|
initialState: {
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
externalSecretsStore = useExternalSecretsStore(pinia);
|
||||||
|
settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
await settingsStore.getSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render paywall state when there is no license', () => {
|
||||||
|
const { getByTestId, queryByTestId } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
expect(queryByTestId('external-secrets-content-licensed')).not.toBeInTheDocument();
|
||||||
|
expect(getByTestId('external-secrets-content-unlicensed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render licensed content', () => {
|
||||||
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
|
||||||
|
|
||||||
|
const { getByTestId, queryByTestId } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
expect(getByTestId('external-secrets-content-licensed')).toBeInTheDocument();
|
||||||
|
expect(queryByTestId('external-secrets-content-unlicensed')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -123,6 +123,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDecrypted(
|
async getDecrypted(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
nodeCredentials: INodeCredentialsDetails,
|
nodeCredentials: INodeCredentialsDetails,
|
||||||
type: string,
|
type: string,
|
||||||
): Promise<ICredentialDataDecryptedObject> {
|
): Promise<ICredentialDataDecryptedObject> {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as tmpl from '@n8n_io/riot-tmpl';
|
||||||
import { DateTime, Duration, Interval } from 'luxon';
|
import { DateTime, Duration, Interval } from 'luxon';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
IDataObject,
|
||||||
IExecuteData,
|
IExecuteData,
|
||||||
INode,
|
INode,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
|
@ -66,8 +67,8 @@ export class Expression {
|
||||||
this.workflow = workflow;
|
this.workflow = workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
static resolveWithoutWorkflow(expression: string) {
|
static resolveWithoutWorkflow(expression: string, data: IDataObject = {}) {
|
||||||
return tmpl.tmpl(expression, {});
|
return tmpl.tmpl(expression, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -225,6 +225,7 @@ export abstract class ICredentialsHelper {
|
||||||
): Promise<ICredentials>;
|
): Promise<ICredentials>;
|
||||||
|
|
||||||
abstract getDecrypted(
|
abstract getDecrypted(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
nodeCredentials: INodeCredentialsDetails,
|
nodeCredentials: INodeCredentialsDetails,
|
||||||
type: string,
|
type: string,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
|
@ -1775,6 +1776,7 @@ export interface IWorkflowExecuteAdditionalData {
|
||||||
executionTimeoutTimestamp?: number;
|
executionTimeoutTimestamp?: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
variables: IDataObject;
|
variables: IDataObject;
|
||||||
|
secretsHelpers: SecretsHelpersBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowExecuteMode =
|
export type WorkflowExecuteMode =
|
||||||
|
@ -2185,6 +2187,7 @@ export interface IN8nUISettings {
|
||||||
variables: boolean;
|
variables: boolean;
|
||||||
sourceControl: boolean;
|
sourceControl: boolean;
|
||||||
auditLogs: boolean;
|
auditLogs: boolean;
|
||||||
|
externalSecrets: boolean;
|
||||||
showNonProdBanner: boolean;
|
showNonProdBanner: boolean;
|
||||||
debugInEditor: boolean;
|
debugInEditor: boolean;
|
||||||
};
|
};
|
||||||
|
@ -2206,4 +2209,15 @@ export interface IN8nUISettings {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretsHelpersBase {
|
||||||
|
update(): Promise<void>;
|
||||||
|
waitForInit(): Promise<void>;
|
||||||
|
|
||||||
|
getSecret(provider: string, name: string): IDataObject | undefined;
|
||||||
|
hasSecret(provider: string, name: string): boolean;
|
||||||
|
hasProvider(provider: string): boolean;
|
||||||
|
listProviders(): string[];
|
||||||
|
listSecrets(provider: string): string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type BannerName = 'V1' | 'TRIAL_OVER' | 'TRIAL' | 'NON_PRODUCTION_LICENSE';
|
export type BannerName = 'V1' | 'TRIAL_OVER' | 'TRIAL' | 'NON_PRODUCTION_LICENSE';
|
||||||
|
|
|
@ -44,6 +44,7 @@ export {
|
||||||
} from './type-guards';
|
} from './type-guards';
|
||||||
|
|
||||||
export { ExpressionExtensions } from './Extensions';
|
export { ExpressionExtensions } from './Extensions';
|
||||||
|
export * as ExpressionParser from './Extensions/ExpressionParser';
|
||||||
export { NativeMethods } from './NativeMethods';
|
export { NativeMethods } from './NativeMethods';
|
||||||
|
|
||||||
export type { DocMetadata, NativeDoc } from './Extensions';
|
export type { DocMetadata, NativeDoc } from './Extensions';
|
||||||
|
|
|
@ -125,6 +125,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDecrypted(
|
async getDecrypted(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
nodeCredentials: INodeCredentialsDetails,
|
nodeCredentials: INodeCredentialsDetails,
|
||||||
type: string,
|
type: string,
|
||||||
): Promise<ICredentialDataDecryptedObject> {
|
): Promise<ICredentialDataDecryptedObject> {
|
||||||
|
|
|
@ -305,6 +305,9 @@ importers:
|
||||||
handlebars:
|
handlebars:
|
||||||
specifier: 4.7.7
|
specifier: 4.7.7
|
||||||
version: 4.7.7
|
version: 4.7.7
|
||||||
|
infisical-node:
|
||||||
|
specifier: ^1.3.0
|
||||||
|
version: 1.3.0
|
||||||
inquirer:
|
inquirer:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.3.3
|
version: 7.3.3
|
||||||
|
@ -8218,6 +8221,16 @@ packages:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|
||||||
|
/axios@1.4.0:
|
||||||
|
resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==}
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.2(debug@4.3.4)
|
||||||
|
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.22.9):
|
/babel-core@7.0.0-bridge.0(@babel/core@7.22.9):
|
||||||
resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==}
|
resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -12918,6 +12931,17 @@ packages:
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/infisical-node@1.3.0:
|
||||||
|
resolution: {integrity: sha512-tTnnExRAO/ZyqiRdnSlBisErNToYWgtunMWh+8opClEt5qjX7l6HC/b4oGo2AuR2Pf41IR+oqo+dzkM1TCvlUA==}
|
||||||
|
dependencies:
|
||||||
|
axios: 1.4.0
|
||||||
|
dotenv: 16.0.3
|
||||||
|
tweetnacl: 1.0.3
|
||||||
|
tweetnacl-util: 0.15.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
dev: false
|
||||||
|
|
||||||
/inflected@2.1.0:
|
/inflected@2.1.0:
|
||||||
resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==}
|
resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -20081,9 +20105,17 @@ packages:
|
||||||
turbo-windows-arm64: 1.10.12
|
turbo-windows-arm64: 1.10.12
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tweetnacl-util@0.15.1:
|
||||||
|
resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tweetnacl@0.14.5:
|
/tweetnacl@0.14.5:
|
||||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||||
|
|
||||||
|
/tweetnacl@1.0.3:
|
||||||
|
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/type-check@0.3.2:
|
/type-check@0.3.2:
|
||||||
resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
|
resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
Loading…
Reference in a new issue