From 656f9c2d7fc635c117efaeb40bb0fb98256f5ba3 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 30 Apr 2026 10:02:40 +0200 Subject: [PATCH] feat(editor): Add data encryption keys settings page (#29068) --- .../frontend/@n8n/i18n/src/locales/en.json | 29 ++ .../src/app/composables/useSettingsItems.ts | 10 + .../editor-ui/src/app/constants/navigation.ts | 1 + packages/frontend/editor-ui/src/app/router.ts | 27 ++ .../encryption-keys/encryption-keys.api.ts | 17 + .../encryption-keys/encryption-keys.store.ts | 114 +++++ .../encryption-keys/encryption-keys.types.ts | 24 + .../views/SettingsEncryptionKeys.test.ts | 123 +++++ .../views/SettingsEncryptionKeys.vue | 423 ++++++++++++++++++ 9 files changed, 768 insertions(+) create mode 100644 packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts create mode 100644 packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts create mode 100644 packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts create mode 100644 packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts create mode 100644 packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d27e48f3d0c..ea2c370524c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4372,6 +4372,35 @@ "settings.security.personalSpace.sharing.existingCount.value": "{workflowCount} workflows, {credentialCount} credentials", "settings.security.personalSpace.unlicensed_tooltip": "Upgrade to a plan that includes personal space policies to manage this setting. {action}", "settings.security.personalSpace.unlicensed_tooltip.link": "Upgrade now", + "settings.encryptionKeys": "Data encryption keys", + "settings.encryptionKeys.title": "Data encryption keys", + "settings.encryptionKeys.description": "Data encryption keys protect credentials, variables, and other sensitive data at rest. Rotating generates a new active key. Past keys are archived and retained for audit.", + "settings.encryptionKeys.description.docsLink": "Learn more in documentation", + "settings.encryptionKeys.column.key": "Key", + "settings.encryptionKeys.column.type": "Type", + "settings.encryptionKeys.column.activated": "Activated", + "settings.encryptionKeys.column.archived": "Archived", + "settings.encryptionKeys.status.active": "Active", + "settings.encryptionKeys.status.inactive": "Inactive", + "settings.encryptionKeys.sortBy.label": "Sort by", + "settings.encryptionKeys.sortBy.activated": "Activated", + "settings.encryptionKeys.sortBy.archived": "Archived", + "settings.encryptionKeys.sortBy.type": "Type", + "settings.encryptionKeys.filter.title": "Filter", + "settings.encryptionKeys.filter.dateRange": "Date range", + "settings.encryptionKeys.filter.apply": "Apply", + "settings.encryptionKeys.filter.clear": "Clear", + "settings.encryptionKeys.rotate.button": "Rotate key", + "settings.encryptionKeys.rotate.confirm.title": "Rotate encryption key?", + "settings.encryptionKeys.rotate.confirm.body": "A new encryption key will be generated and used to encrypt new data. The previous key will be archived and kept to decrypt existing data.", + "settings.encryptionKeys.rotate.confirm.action": "Rotate key", + "settings.encryptionKeys.rotate.confirm.cancel": "Cancel", + "settings.encryptionKeys.rotate.success": "Encryption key rotated", + "settings.encryptionKeys.rotate.error": "Could not rotate encryption key", + "settings.encryptionKeys.copyId.success": "Key ID copied to clipboard", + "settings.encryptionKeys.empty.title": "No encryption keys yet", + "settings.encryptionKeys.empty.description": "Rotate a key to generate your first active encryption key.", + "settings.encryptionKeys.loadError": "Could not load encryption keys", "settings.sso": "SSO", "settings.sso.title": "Single Sign-On", "settings.sso.subtitle": "SAML 2.0 Configuration", diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index 6cff78377fb..c557fe90235 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -119,6 +119,16 @@ export function useSettingsItems() { available: canUserAccessRouteByName(VIEWS.SSO_SETTINGS), route: { to: { name: VIEWS.SSO_SETTINGS } }, }, + { + id: 'settings-encryption-keys', + icon: 'key-round', + label: i18n.baseText('settings.encryptionKeys'), + position: 'top', + available: + envFeatureFlagCheck.value('ENCRYPTION_KEY_ROTATION') && + canUserAccessRouteByName(VIEWS.ENCRYPTION_KEYS_SETTINGS), + route: { to: { name: VIEWS.ENCRYPTION_KEYS_SETTINGS } }, + }, { id: 'settings-security', icon: 'shield', diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts index 243b7a268a1..934e6391f7b 100644 --- a/packages/frontend/editor-ui/src/app/constants/navigation.ts +++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts @@ -37,6 +37,7 @@ export const enum VIEWS { USAGE = 'Usage', LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView', SSO_SETTINGS = 'SSoSettings', + ENCRYPTION_KEYS_SETTINGS = 'EncryptionKeysSettings', EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings', SAML_ONBOARDING = 'SamlOnboarding', SOURCE_CONTROL = 'SourceControl', diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index dcfb177d26b..4278f304f60 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -71,6 +71,8 @@ const TemplatesSearchView = async () => const SettingsUsageAndPlan = async () => await import('@/features/settings/usage/views/SettingsUsageAndPlan.vue'); const SettingsSso = async () => await import('@/features/settings/sso/views/SettingsSso.vue'); +const SettingsEncryptionKeys = async () => + await import('@/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue'); const SignoutView = async () => await import('@/features/core/auth/views/SignoutView.vue'); const SamlOnboarding = async () => await import('@/features/settings/sso/views/SamlOnboarding.vue'); const SettingsSourceControl = async () => @@ -926,6 +928,31 @@ export const routes: RouteRecordRaw[] = [ }, }, }, + { + path: 'encryption-keys', + name: VIEWS.ENCRYPTION_KEYS_SETTINGS, + component: SettingsEncryptionKeys, + meta: { + middleware: ['authenticated', 'rbac', 'custom'], + middlewareOptions: { + rbac: { + scope: 'encryptionKey:manage', + }, + custom: () => { + const { check } = useEnvFeatureFlag(); + return check.value('ENCRYPTION_KEY_ROTATION'); + }, + }, + telemetry: { + pageCategory: 'settings', + getProperties() { + return { + feature: 'encryption-keys', + }; + }, + }, + }, + }, { path: 'log-streaming', name: VIEWS.LOG_STREAMING_SETTINGS, diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts new file mode 100644 index 00000000000..c6901a3f43f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts @@ -0,0 +1,17 @@ +import type { CreateEncryptionKeyDto } from '@n8n/api-types'; +import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client'; + +import type { EncryptionKey } from './encryption-keys.types'; + +const ENDPOINT = '/encryption/keys'; + +export const getEncryptionKeys = async (context: IRestApiContext): Promise => { + return await makeRestApiRequest(context, 'GET', ENDPOINT, { + type: 'data_encryption', + }); +}; + +export const rotateEncryptionKey = async (context: IRestApiContext): Promise => { + const payload: CreateEncryptionKeyDto = { type: 'data_encryption' }; + return await makeRestApiRequest(context, 'POST', ENDPOINT, { ...payload }); +}; diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts new file mode 100644 index 00000000000..3b7156874e4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts @@ -0,0 +1,114 @@ +import { computed, ref } from 'vue'; +import { defineStore } from 'pinia'; +import { useRootStore } from '@n8n/stores/useRootStore'; + +import * as encryptionKeysApi from './encryption-keys.api'; +import type { + EncryptionKey, + EncryptionKeyFilters, + EncryptionKeySort, +} from './encryption-keys.types'; + +const DEFAULT_SORT: EncryptionKeySort = { field: 'createdAt', direction: 'desc' }; + +const DEFAULT_FILTERS: EncryptionKeyFilters = { + activatedFrom: null, + activatedTo: null, +}; + +export const useEncryptionKeysStore = defineStore('encryptionKeys', () => { + const rootStore = useRootStore(); + + const keys = ref([]); + const isLoading = ref(false); + const isRotating = ref(false); + const sort = ref({ ...DEFAULT_SORT }); + const filters = ref({ ...DEFAULT_FILTERS }); + + const compareKeys = (a: EncryptionKey, b: EncryptionKey, field: EncryptionKeySort['field']) => { + if (field === 'status') { + return a.status.localeCompare(b.status); + } + + if (field === 'updatedAt') { + const valueA = a.status === 'inactive' ? a.updatedAt : null; + const valueB = b.status === 'inactive' ? b.updatedAt : null; + if (valueA === null && valueB === null) return 0; + if (valueA === null) return 1; + if (valueB === null) return -1; + return new Date(valueA).getTime() - new Date(valueB).getTime(); + } + + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }; + + const matchesFilters = (key: EncryptionKey) => { + const { activatedFrom, activatedTo } = filters.value; + const activatedAt = new Date(key.createdAt).getTime(); + + if (activatedFrom && activatedAt < new Date(activatedFrom).getTime()) return false; + if (activatedTo && activatedAt > new Date(activatedTo).getTime()) return false; + + return true; + }; + + const visibleKeys = computed(() => { + const filtered = keys.value.filter(matchesFilters); + const sorted = [...filtered].sort((a, b) => { + const diff = compareKeys(a, b, sort.value.field); + return sort.value.direction === 'asc' ? diff : -diff; + }); + return sorted; + }); + + const activeKey = computed(() => keys.value.find((key) => key.status === 'active') ?? null); + + const isEmpty = computed(() => !isLoading.value && keys.value.length === 0); + + const fetchKeys = async () => { + isLoading.value = true; + try { + keys.value = await encryptionKeysApi.getEncryptionKeys(rootStore.restApiContext); + } finally { + isLoading.value = false; + } + }; + + const rotateKey = async () => { + isRotating.value = true; + try { + await encryptionKeysApi.rotateEncryptionKey(rootStore.restApiContext); + await fetchKeys(); + } finally { + isRotating.value = false; + } + }; + + const setSort = (next: EncryptionKeySort) => { + sort.value = { ...next }; + }; + + const setFilters = (next: Partial) => { + filters.value = { ...filters.value, ...next }; + }; + + const resetFilters = () => { + filters.value = { ...DEFAULT_FILTERS }; + }; + + return { + keys, + visibleKeys, + activeKey, + isLoading, + isRotating, + isEmpty, + sort, + filters, + fetchKeys, + rotateKey, + setSort, + setFilters, + resetFilters, + }; +}); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts new file mode 100644 index 00000000000..e41f9a8f52b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts @@ -0,0 +1,24 @@ +import type { EncryptionKeyResponseDto } from '@n8n/api-types'; + +export type EncryptionKeyStatus = 'active' | 'inactive'; + +/** + * For an inactive key, `updatedAt` is effectively the archive date: when a key + * transitions from `active` to `inactive` its `updatedAt` is bumped, and that + * transition cannot be undone. + */ +export type EncryptionKey = Omit & { + status: EncryptionKeyStatus; +}; + +export type EncryptionKeySortField = 'createdAt' | 'updatedAt' | 'status'; + +export type EncryptionKeySort = { + field: EncryptionKeySortField; + direction: 'asc' | 'desc'; +}; + +export type EncryptionKeyFilters = { + activatedFrom: string | null; + activatedTo: string | null; +}; diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts new file mode 100644 index 00000000000..26461072625 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts @@ -0,0 +1,123 @@ +import { fireEvent, screen, waitFor } from '@testing-library/vue'; +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; + +import { renderComponent } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; + +import SettingsEncryptionKeys from './SettingsEncryptionKeys.vue'; +import { useEncryptionKeysStore } from '../encryption-keys.store'; +import type { EncryptionKey } from '../encryption-keys.types'; + +vi.mock('@/app/composables/useClipboard', () => ({ + useClipboard: () => ({ copy: vi.fn() }), +})); + +const showMessage = vi.fn(); +const showError = vi.fn(); +vi.mock('@/app/composables/useToast', () => ({ + useToast: () => ({ showMessage, showError }), +})); + +const makeKey = (overrides: Partial = {}): EncryptionKey => ({ + id: '4d51a27b8f03a6d20863', + type: 'data_encryption', + algorithm: 'aes-256-gcm', + status: 'active', + createdAt: '2026-04-21T10:00:00.000Z', + updatedAt: '2026-04-21T10:00:00.000Z', + ...overrides, +}); + +describe('SettingsEncryptionKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createTestingPinia({ stubActions: false })); + }); + + it('renders a row for each encryption key with a masked id', async () => { + const store = mockedStore(useEncryptionKeysStore); + store.keys = [ + makeKey(), + makeKey({ + id: '74f6c1e9b4d8a2f51234', + status: 'inactive', + createdAt: '2026-03-15T10:00:00.000Z', + }), + ]; + store.visibleKeys = store.keys; + store.fetchKeys.mockResolvedValue(undefined); + + renderComponent(SettingsEncryptionKeys); + + await waitFor(() => { + expect(screen.getByText(/4d51.{0,20}0863/)).toBeInTheDocument(); + expect(screen.getByText(/74f6.{0,20}1234/)).toBeInTheDocument(); + }); + }); + + it('shows active and inactive status badges', async () => { + const store = mockedStore(useEncryptionKeysStore); + store.keys = [ + makeKey({ status: 'active' }), + makeKey({ + id: '74f6c1e9b4d8a2f51234', + status: 'inactive', + createdAt: '2026-03-15T10:00:00.000Z', + }), + ]; + store.visibleKeys = store.keys; + store.fetchKeys.mockResolvedValue(undefined); + + renderComponent(SettingsEncryptionKeys); + + await waitFor(() => { + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); + }); + + it('rotates the key after confirmation and reports success', async () => { + const store = mockedStore(useEncryptionKeysStore); + store.keys = [makeKey()]; + store.visibleKeys = store.keys; + store.fetchKeys.mockResolvedValue(undefined); + store.rotateKey.mockResolvedValue(undefined); + + renderComponent(SettingsEncryptionKeys); + + const rotateButtons = await screen.findAllByRole('button', { name: /Rotate key/i }); + await fireEvent.click(rotateButtons[0]); + + const confirmButtons = await screen.findAllByRole('button', { name: /Rotate key/i }); + const dialogConfirm = confirmButtons[confirmButtons.length - 1]; + await fireEvent.click(dialogConfirm); + + await waitFor(() => { + expect(store.rotateKey).toHaveBeenCalledTimes(1); + expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + }); + + it('surfaces rotate errors via showError', async () => { + const store = mockedStore(useEncryptionKeysStore); + store.keys = [makeKey()]; + store.visibleKeys = store.keys; + store.fetchKeys.mockResolvedValue(undefined); + const error = new Error('rotate failed'); + store.rotateKey.mockRejectedValue(error); + + renderComponent(SettingsEncryptionKeys); + + const rotateButtons = await screen.findAllByRole('button', { name: /Rotate key/i }); + await fireEvent.click(rotateButtons[0]); + + const confirmButtons = await screen.findAllByRole('button', { name: /Rotate key/i }); + const dialogConfirm = confirmButtons[confirmButtons.length - 1]; + await fireEvent.click(dialogConfirm); + + await waitFor(() => { + expect(showError).toHaveBeenCalledWith(error, expect.any(String)); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue new file mode 100644 index 00000000000..591bcae6714 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue @@ -0,0 +1,423 @@ + + + + +