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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user