diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d7099b585a7..e2893bd10c4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3921,8 +3921,7 @@ "workflowSettings.helpTexts.redactManualData": "Controls whether execution data from manually triggered executions is redacted.", "workflowSettings.redactionPermissionNotice": "You don't have permission to change data redaction settings.", "workflowSettings.redactionPermissionNotice.viewUsers": "View users with access", - "workflowSettings.redactionEnforcementNotice": "Enforced at instance level", - "workflowSettings.redactionEnforcementNotice.label": "Enforced at instance level", + "workflowSettings.redactionFloorNotice": "This option is enforced by your instance's redaction policy.", "workflowSettings.redactionMembersModal.title": "Users who can edit data redaction settings", "workflowSettings.redactionMembersModal.description": "These users can change data redaction settings. Contact one of them to request a change.", "workflowSettings.saveManualExecutions": "Save manual executions", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts index 0a3e2f7de6c..ac4cf94708b 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts @@ -1401,55 +1401,21 @@ describe('WorkflowSettingsVue', () => { await flushPromises(); // No other lock is in play. - expect(queryByTestId('workflow-settings-redaction-enforced-lock')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-settings-redaction-floor-lock')).not.toBeInTheDocument(); const manualCombobox = within( getByTestId('workflow-settings-redact-manual-select'), ).getByRole('combobox'); expect(manualCombobox).toBeDisabled(); }); - - it('shows the enforcement lock copy (not the new hint) when instance enforcement is also on', async () => { - vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); - settingsStore.settings.enterprise[EnterpriseEditionFeature.DataRedaction] = true; - settingsStore.settings.envFeatureFlags = { - ...settingsStore.settings.envFeatureFlags, - N8N_ENV_FEAT_REDACTION_ENFORCEMENT: 'true', - }; - getSecuritySettings.mockResolvedValue({ - ...DEFAULT_SECURITY_SETTINGS, - redactionEnforcement: { floor: 'production' }, - }); - projectsStore.personalProject = mock({ - scopes: ['workflow:enableRedaction', 'workflow:disableRedaction'], - }); - const workflow = createTestWorkflow({ - id: '1', - name: 'Test Workflow', - active: true, - scopes: ['workflow:update', 'workflow:enableRedaction', 'workflow:disableRedaction'], - }); - workflowsListStore.workflowsById = { '1': workflow }; - workflowsListStore.getWorkflowById.mockImplementation(() => workflow); - workflowDocumentStore.setSettings({ redactionPolicy: 'none' }); - - const { getAllByTestId, queryByText } = createComponent({ pinia }); - await flushPromises(); - - expect(getAllByTestId('workflow-settings-redaction-enforced-lock')).toHaveLength(2); - expect( - queryByText( - 'Manual execution data can only be redacted when production execution data is also redacted.', - ), - ).not.toBeInTheDocument(); - }); }); - describe('instance enforcement', () => { - const setUpEnforcement = (params: { - enforced: boolean; + describe('instance floor', () => { + const setUpFloor = (params: { + floor: 'off' | 'production' | 'all'; flagEnabled: boolean; hasUpdatePermission?: boolean; + redactionPolicy?: 'none' | 'non-manual' | 'manual-only' | 'all'; }) => { vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); settingsStore.settings.enterprise[EnterpriseEditionFeature.DataRedaction] = true; @@ -1459,7 +1425,7 @@ describe('WorkflowSettingsVue', () => { }; getSecuritySettings.mockResolvedValue({ ...DEFAULT_SECURITY_SETTINGS, - redactionEnforcement: { floor: params.enforced ? 'production' : 'off' }, + redactionEnforcement: { floor: params.floor }, }); const hasPermission = params.hasUpdatePermission ?? true; @@ -1479,12 +1445,40 @@ describe('WorkflowSettingsVue', () => { }); workflowsListStore.workflowsById = { '1': workflow }; workflowsListStore.getWorkflowById.mockImplementation(() => workflow); + + if (params.redactionPolicy) { + workflowDocumentStore.setSettings({ redactionPolicy: params.redactionPolicy }); + } }; - it('locks both redaction selects with enforcement copy when enforcement is on', async () => { - setUpEnforcement({ enforced: true, flagEnabled: true }); + it('locks production select with floor copy under floor "production"; manual stays editable', async () => { + setUpFloor({ floor: 'production', flagEnabled: true, redactionPolicy: 'none' }); - const { getByTestId, getAllByTestId, queryByText } = createComponent({ pinia }); + const { getByTestId, getAllByTestId } = createComponent({ pinia }); + await flushPromises(); + + const productionInput = within( + getByTestId('workflow-settings-redact-production-select'), + ).getByRole('combobox'); + expect(productionInput).toBeDisabled(); + expect( + getByTestId('workflow-settings-redact-production-select').querySelector('input')?.value, + ).toBe('Redact'); + + const floorIcons = getAllByTestId('workflow-settings-redaction-floor-lock'); + expect(floorIcons).toHaveLength(1); + + // Production was coerced to 'redact', so the IAM-697 rule no longer blocks the manual select. + const manualInput = within(getByTestId('workflow-settings-redact-manual-select')).getByRole( + 'combobox', + ); + expect(manualInput).not.toBeDisabled(); + }); + + it('locks both selects with floor copy under floor "all"', async () => { + setUpFloor({ floor: 'all', flagEnabled: true, redactionPolicy: 'none' }); + + const { getByTestId, getAllByTestId } = createComponent({ pinia }); await flushPromises(); const productionInput = within( @@ -1496,18 +1490,18 @@ describe('WorkflowSettingsVue', () => { expect(productionInput).toBeDisabled(); expect(manualInput).toBeDisabled(); - const lockIcons = getAllByTestId('workflow-settings-redaction-enforced-lock'); - expect(lockIcons).toHaveLength(2); + expect( + getByTestId('workflow-settings-redact-production-select').querySelector('input')?.value, + ).toBe('Redact'); + expect( + getByTestId('workflow-settings-redact-manual-select').querySelector('input')?.value, + ).toBe('Redact'); - // Permission-only tooltip copy must not be shown when enforcement is the lock reason. - expect(queryByText('View users with access')).not.toBeInTheDocument(); + expect(getAllByTestId('workflow-settings-redaction-floor-lock')).toHaveLength(2); }); - it('leaves both redaction selects editable when enforcement is off', async () => { - setUpEnforcement({ enforced: false, flagEnabled: true }); - // Seed with production=redact so the manual select is editable (the new - // workflow-level invariant disables manual when production is default). - workflowDocumentStore.setSettings({ redactionPolicy: 'non-manual' }); + it('leaves both selects editable under floor "off"', async () => { + setUpFloor({ floor: 'off', flagEnabled: true, redactionPolicy: 'non-manual' }); const { getByTestId, queryByTestId } = createComponent({ pinia }); await flushPromises(); @@ -1520,11 +1514,12 @@ describe('WorkflowSettingsVue', () => { ); expect(productionInput).not.toBeDisabled(); expect(manualInput).not.toBeDisabled(); - expect(queryByTestId('workflow-settings-redaction-enforced-lock')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-settings-redaction-floor-lock')).not.toBeInTheDocument(); + expect(getSecuritySettings).toHaveBeenCalled(); }); - it('ignores enforcement state when the feature flag is off', async () => { - setUpEnforcement({ enforced: true, flagEnabled: false }); + it('does not apply the floor lock when the feature flag is off', async () => { + setUpFloor({ floor: 'all', flagEnabled: false, redactionPolicy: 'non-manual' }); const { getByTestId, queryByTestId } = createComponent({ pinia }); await flushPromises(); @@ -1532,19 +1527,125 @@ describe('WorkflowSettingsVue', () => { const productionInput = within( getByTestId('workflow-settings-redact-production-select'), ).getByRole('combobox'); + const manualInput = within(getByTestId('workflow-settings-redact-manual-select')).getByRole( + 'combobox', + ); expect(productionInput).not.toBeDisabled(); - expect(queryByTestId('workflow-settings-redaction-enforced-lock')).not.toBeInTheDocument(); + expect(manualInput).not.toBeDisabled(); + expect(queryByTestId('workflow-settings-redaction-floor-lock')).not.toBeInTheDocument(); expect(getSecuritySettings).not.toHaveBeenCalled(); }); - it('prefers enforcement copy when both enforcement and missing permission would lock', async () => { - setUpEnforcement({ enforced: true, flagEnabled: true, hasUpdatePermission: false }); + it('keeps manual select enabled under floor "production" (production coerced to redact)', async () => { + setUpFloor({ floor: 'production', flagEnabled: true, redactionPolicy: 'none' }); + + const { getByTestId, queryByText } = createComponent({ pinia }); + await flushPromises(); + + const manualInput = within(getByTestId('workflow-settings-redact-manual-select')).getByRole( + 'combobox', + ); + expect(manualInput).not.toBeDisabled(); + // IAM-697 hint must not be shown — production is forced to redact. + expect( + queryByText( + 'Manual execution data can only be redacted when production execution data is also redacted.', + ), + ).not.toBeInTheDocument(); + }); + + it('shows the floor lock on manual (not the IAM-697 hint) when floor "all" and policy "none"', async () => { + setUpFloor({ floor: 'all', flagEnabled: true, redactionPolicy: 'none' }); const { getAllByTestId, queryByText } = createComponent({ pinia }); await flushPromises(); - expect(getAllByTestId('workflow-settings-redaction-enforced-lock')).toHaveLength(2); - expect(queryByText('View users with access')).not.toBeInTheDocument(); + expect(getAllByTestId('workflow-settings-redaction-floor-lock')).toHaveLength(2); + expect( + queryByText( + 'Manual execution data can only be redacted when production execution data is also redacted.', + ), + ).not.toBeInTheDocument(); + }); + + it('persists coerced redactionPolicy on save under floor "production"', async () => { + setUpFloor({ floor: 'production', flagEnabled: true, redactionPolicy: 'none' }); + + const { getByRole } = createComponent({ pinia }); + await flushPromises(); + + toast.showError.mockClear(); + await userEvent.click(getByRole('button', { name: 'Save' })); + expect(toast.showError).not.toHaveBeenCalled(); + + expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + settings: expect.objectContaining({ redactionPolicy: 'non-manual' }), + }), + ); + }); + + it('persists coerced redactionPolicy on save under floor "all"', async () => { + setUpFloor({ floor: 'all', flagEnabled: true, redactionPolicy: 'none' }); + + const { getByRole } = createComponent({ pinia }); + await flushPromises(); + + toast.showError.mockClear(); + await userEvent.click(getByRole('button', { name: 'Save' })); + expect(toast.showError).not.toHaveBeenCalled(); + + expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + settings: expect.objectContaining({ redactionPolicy: 'all' }), + }), + ); + }); + + it('fails open when getSecuritySettings rejects (no floor lock, selects editable)', async () => { + setUpFloor({ floor: 'production', flagEnabled: true, redactionPolicy: 'non-manual' }); + // Override the resolved mock with a rejection — the component must swallow the error + // and leave instanceRedactionFloor at its default 'off'. + getSecuritySettings.mockRejectedValueOnce(new Error('Network error')); + + const { getByTestId, queryByTestId } = createComponent({ pinia }); + await flushPromises(); + + const productionInput = within( + getByTestId('workflow-settings-redact-production-select'), + ).getByRole('combobox'); + const manualInput = within(getByTestId('workflow-settings-redact-manual-select')).getByRole( + 'combobox', + ); + expect(productionInput).not.toBeDisabled(); + expect(manualInput).not.toBeDisabled(); + expect(queryByTestId('workflow-settings-redaction-floor-lock')).not.toBeInTheDocument(); + }); + + it('keeps the permission lock active when the flag is off (no floor lock applies)', async () => { + setUpFloor({ + floor: 'all', + flagEnabled: false, + hasUpdatePermission: false, + redactionPolicy: 'all', + }); + + const { getByTestId, queryByTestId } = createComponent({ pinia }); + await flushPromises(); + + const productionInput = within( + getByTestId('workflow-settings-redact-production-select'), + ).getByRole('combobox'); + const manualInput = within(getByTestId('workflow-settings-redact-manual-select')).getByRole( + 'combobox', + ); + expect(productionInput).toBeDisabled(); + expect(manualInput).toBeDisabled(); + + // No floor-lock indicator under flag-off (the lock comes from missing permission). + expect(queryByTestId('workflow-settings-redaction-floor-lock')).not.toBeInTheDocument(); }); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index 53bd7ed9608..3923851b853 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -55,6 +55,7 @@ import { useCredentialResolvers } from '@/features/resolvers/composables/useCred import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; import { useRedactionEnforcementFeatureFlag } from '@/features/redaction-enforcement/composables/useRedactionEnforcementFeatureFlag'; import * as securitySettingsApi from '@n8n/rest-api-client/api/security-settings'; +import type { RedactionFloor } from '@n8n/api-types'; import { hasPermission } from '@/app/utils/rbac/permissions'; import { ElCol, ElRow, ElSwitch } from 'element-plus'; @@ -70,7 +71,7 @@ const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); const pageRedirectionHelper = usePageRedirectionHelper(); const { isEnabled: isCredentialResolverEnabled } = useDynamicCredentials(); const { isEnabled: isRedactionEnforcementFlagEnabled } = useRedactionEnforcementFeatureFlag(); -const isInstanceRedactionEnforced = ref(false); +const instanceRedactionFloor = ref('off'); const canListCredentialResolvers = hasPermission(['rbac'], { rbac: { scope: 'credentialResolver:list' }, }); @@ -239,14 +240,25 @@ const isDataRedactionLicensed = computed( const isRedactionSettingVisible = computed(() => settingsStore.isModuleActive('redaction')); -const isRedactionEnforcedByInstance = computed( - () => isRedactionEnforcementFlagEnabled.value && isInstanceRedactionEnforced.value, +const isProductionRedactionLockedByFloor = computed( + () => + isRedactionEnforcementFlagEnabled.value && + (instanceRedactionFloor.value === 'production' || instanceRedactionFloor.value === 'all'), ); -// Enforcement wins over permission — an admin cannot override an instance-level policy. -function getRedactionLockReason(currentValue: string): 'enforcement' | 'permission' | null { +const isManualRedactionLockedByFloor = computed( + () => isRedactionEnforcementFlagEnabled.value && instanceRedactionFloor.value === 'all', +); + +type RedactionLockReason = 'floor' | 'permission' | null; + +// Floor lock wins over permission — an admin cannot override an instance-level policy. +function resolveRedactionLockReason( + currentValue: string, + lockedByFloor: boolean, +): RedactionLockReason { if (!isDataRedactionLicensed.value) return null; - if (isRedactionEnforcedByInstance.value) return 'enforcement'; + if (lockedByFloor) return 'floor'; if ( currentValue === 'default' ? !projectPermissions.value.workflow.enableRedaction @@ -257,10 +269,12 @@ function getRedactionLockReason(currentValue: string): 'enforcement' | 'permissi } const productionRedactionLockReason = computed(() => - getRedactionLockReason(redactProductionData.value), + resolveRedactionLockReason(redactProductionData.value, isProductionRedactionLockedByFloor.value), ); -const manualRedactionLockReason = computed(() => getRedactionLockReason(redactManualData.value)); +const manualRedactionLockReason = computed(() => + resolveRedactionLockReason(redactManualData.value, isManualRedactionLockedByFloor.value), +); const isProductionRedactionLocked = computed(() => productionRedactionLockReason.value !== null); const isManualRedactionLocked = computed(() => manualRedactionLockReason.value !== null); @@ -328,6 +342,22 @@ watch(redactProductionData, (newVal) => { } }); +// Coerce the locked select(s) to 'redact' when an instance floor applies. +// No `immediate: true` — the natural change of `instanceRedactionFloor` from +// its `'off'` default to the fetched value triggers this after both the +// floor and workflow settings have settled in `onMounted`. +watch( + [isProductionRedactionLockedByFloor, isManualRedactionLockedByFloor], + ([prodLocked, manualLocked]) => { + if (prodLocked && redactProductionData.value !== 'redact') { + redactProductionData.value = 'redact'; + } + if (manualLocked && redactManualData.value !== 'redact') { + redactManualData.value = 'redact'; + } + }, +); + const mcpToggleDisabled = computed(() => { return readOnlyEnv.value || !workflowPermissions.value.update; }); @@ -750,15 +780,6 @@ const onExecutionLogicModeChange = (value: string) => { }; onMounted(async () => { - if (isRedactionEnforcementFlagEnabled.value) { - try { - const response = await securitySettingsApi.getSecuritySettings(rootStore.restApiContext); - isInstanceRedactionEnforced.value = (response.redactionEnforcement?.floor ?? 'off') !== 'off'; - } catch (error) { - console.debug('Failed to fetch redaction enforcement state', error); - } - } - executionTimeout.value = rootStore.executionTimeout; maxExecutionTimeout.value = rootStore.maxExecutionTimeout; @@ -858,6 +879,17 @@ onMounted(async () => { originalBinaryMode.value = workflowSettingsData.binaryMode; workflowSettings.value = workflowSettingsData; + // Fetch the instance redaction floor AFTER workflowSettings has been assigned, so + // the floor-coercion watch sees the loaded settings (not the initial empty object). + if (isRedactionEnforcementFlagEnabled.value) { + try { + const response = await securitySettingsApi.getSecuritySettings(rootStore.restApiContext); + instanceRedactionFloor.value = response.redactionEnforcement?.floor ?? 'off'; + } catch (error) { + console.debug('Failed to fetch redaction enforcement state', error); + } + } + // Clear stale credential resolver references (resolver was deleted externally) // Only clear if resolvers loaded successfully — on API failure the list is empty // and we must not falsely treat a valid ID as stale. @@ -1265,8 +1297,8 @@ onBeforeUnmount(() => { size="xsmall" style="opacity: 1" :data-test-id=" - productionRedactionLockReason === 'enforcement' - ? 'workflow-settings-redaction-enforced-lock' + productionRedactionLockReason === 'floor' + ? 'workflow-settings-redaction-floor-lock' : undefined " /> @@ -1295,8 +1327,8 @@ onBeforeUnmount(() => { placement="top" >