feat: External Secrets storage for credentials (#6477)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Romain Minaud <romain.minaud@gmail.com>
Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
Alex Grozav
2023-08-25 11:33:46 +03:00
committed by GitHub
parent c833078c87
commit ed927d34b2
89 changed files with 4164 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 dont need to leave this app open all the time for your workflows to run.",
"settings.externalSecrets.title": "External Secrets",
"settings.externalSecrets.info": "Connect external secrets tools for centralized credentials management across environments, and to enhance system security.",
"settings.externalSecrets.info.link": "More info",
"settings.externalSecrets.actionBox.title": "Available on the Enterprise plan",
"settings.externalSecrets.actionBox.description": "Connect external secrets tools for centralized credentials management across instances. {link}",
"settings.externalSecrets.actionBox.description.link": "More info",
"settings.externalSecrets.actionBox.buttonText": "See plans",
"settings.externalSecrets.card.setUp": "Set Up",
"settings.externalSecrets.card.secretsCount": "{count} secrets",
"settings.externalSecrets.card.connectedAt": "Connected {date}",
"settings.externalSecrets.card.connected": "Enabled",
"settings.externalSecrets.card.disconnected": "Disabled",
"settings.externalSecrets.card.actionDropdown.setup": "Edit connection",
"settings.externalSecrets.card.actionDropdown.reload": "Reload secrets",
"settings.externalSecrets.card.reload.success.title": "Reloaded successfully",
"settings.externalSecrets.card.reload.success.description": "All secrets have been reloaded from {provider}.",
"settings.externalSecrets.provider.title": "Commit and push changes",
"settings.externalSecrets.provider.description": "Select the files you want to stage in your commit and add a commit message. ",
"settings.externalSecrets.provider.buttons.cancel": "Cancel",
"settings.externalSecrets.provider.buttons.save": "Save",
"settings.externalSecrets.provider.buttons.saving": "Saving",
"settings.externalSecrets.card.connectedSwitch.title": "Enable {provider}",
"settings.externalSecrets.provider.save.success.title": "Provider settings saved successfully",
"settings.externalSecrets.provider.connected.success.title": "Provider connected successfully",
"settings.externalSecrets.provider.disconnected.success.title": "Provider disconnected successfully",
"settings.externalSecrets.provider.testConnection.success.connected": "Service enabled, {count} secrets available on {provider}.",
"settings.externalSecrets.provider.testConnection.success.connected.usage": "Use secrets in credentials by setting a parameter to an expression and typing: {code}. ",
"settings.externalSecrets.provider.testConnection.success.connected.docs": "More info",
"settings.externalSecrets.provider.testConnection.success": "Connection to {provider} executed successfully. Enable the service to use the secrets in credentials.",
"settings.externalSecrets.provider.testConnection.error.connected": "Connection unsuccessful, please check your {provider} settings",
"settings.externalSecrets.provider.testConnection.error": "Connection unsuccessful, please check your {provider} settings",
"settings.externalSecrets.provider.closeWithoutSaving.title": "Close without saving?",
"settings.externalSecrets.provider.closeWithoutSaving.description": "Are you sure you want to throw away the changes you made to the {provider} settings?",
"settings.externalSecrets.provider.closeWithoutSaving.cancel": "Close",
"settings.externalSecrets.provider.closeWithoutSaving.confirm": "Keep editing",
"settings.externalSecrets.docs": "https://docs.n8n.io/external-secrets/",
"settings.externalSecrets.docs.use": "https://docs.n8n.io/external-secrets/#use-secrets-in-n8n-credentials",
"settings.sourceControl.title": "Environments",
"settings.sourceControl.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.",

View File

@@ -12,6 +12,18 @@ export const faVariable: IconDefinition = {
],
};
export const faVault: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'vault' as IconName,
icon: [
576,
512,
[],
'e006',
'M64 0C28.7 0 0 28.7 0 64v352c0 35.3 28.7 64 64 64h16l16 32h64l16-32h224l16 32h64l16-32h16c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zm160 320a80 80 0 1 0 0-160a80 80 0 1 0 0 160zm0-240a160 160 0 1 1 0 320a160 160 0 1 1 0-320zm256 141.3V336c0 8.8-7.2 16-16 16s-16-7.2-16-16V221.3c-18.6-6.6-32-24.4-32-45.3c0-26.5 21.5-48 48-48s48 21.5 48 48c0 20.9-13.4 38.7-32 45.3z',
],
};
export const faXmark: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'xmark' as IconName,

View File

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