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:
Alex Grozav 2023-08-25 11:33:46 +03:00 committed by GitHub
parent c833078c87
commit ed927d34b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 4164 additions and 57 deletions

View file

@ -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",

View file

@ -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;
}
} }
/** /**

View file

@ -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();
}
}

View 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);
}
}

View 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;
}
}
}

View file

@ -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;
}
}

View 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_]+$/;

View file

@ -0,0 +1,7 @@
import { License } from '@/License';
import Container from 'typedi';
export function isExternalSecretsEnabled() {
const license = Container.get(License);
return license.isExternalSecretsEnabled();
}

View 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;
}
}

View 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);
}
}

View file

@ -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';

View file

@ -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);
}
} }

View file

@ -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);
} }

View 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) ?? [];
}
}

View file

@ -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,

View file

@ -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),
}; };
} }

View file

@ -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) {

View file

@ -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();

View file

@ -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() {

View file

@ -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() {

View file

@ -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',

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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 });

View file

@ -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',

View file

@ -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 }>;
}

View file

@ -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'],
});
});
});

View file

@ -26,6 +26,7 @@ export type EndpointGroup =
| 'license' | 'license'
| 'variables' | 'variables'
| 'tags' | 'tags'
| 'externalSecrets'
| 'mfa' | 'mfa'
| 'metrics'; | 'metrics';

View file

@ -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;
} }
} }
} }

View 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);
}
}

View file

@ -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(() => {

View file

@ -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);
});
});

View file

@ -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,

View 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();
},
},
);
}

View file

@ -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({

View file

@ -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> &nbsp; <n8n-text-stub bold=\\"false\\" size=\\"small\\" compact=\\"false\\" tag=\\"span\\"></n8n-text-stub> &nbsp;
</div> </div>

View file

@ -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;

View 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;
}

View file

@ -1,4 +1,5 @@
@import 'float'; @import 'float';
@import 'link';
@import 'list'; @import 'list';
@import 'spacing'; @import 'spacing';
@import 'typography'; @import 'typography';

View file

@ -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'

View 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,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -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);

View file

@ -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),
};
},
},
});

View file

@ -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)}\..*`);

View file

@ -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,

View file

@ -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) {

View file

@ -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),

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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,
}), }),

View file

@ -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,
}, },

View file

@ -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';
} }
} }

View file

@ -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() {

View file

@ -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) {

View file

@ -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';

View 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,
};
}

View file

@ -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',
} }

View file

@ -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);

View file

@ -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 = {

View file

@ -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;

View file

@ -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.
*/ */

View file

@ -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');

View file

@ -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 = () =>

View file

@ -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 dont 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 dont 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.",

View file

@ -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,

View file

@ -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);

View file

@ -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,

View 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,
};
});

View file

@ -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';

View file

@ -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,
}, },

View 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());
});
};

View file

@ -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';

View file

@ -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) {

View file

@ -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) {

View 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>

View file

@ -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();
});
});

View file

@ -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> {

View file

@ -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);
} }
/** /**

View file

@ -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';

View file

@ -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';

View file

@ -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> {

View file

@ -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'}