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