feat(editor): Apply instance redaction floor per-select in workflow settings (#31229)

This commit is contained in:
Csaba Tuncsik 2026-05-29 14:05:12 +02:00 committed by GitHub
parent 153f6c47b4
commit d431710a4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 219 additions and 87 deletions

View File

@ -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",

View File

@ -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<Project>({
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();
});
});
});

View File

@ -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<RedactionFloor>('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"
>
<template #content>
<span v-if="productionRedactionLockReason === 'enforcement'">{{
i18n.baseText('workflowSettings.redactionEnforcementNotice')
<span v-if="productionRedactionLockReason === 'floor'">{{
i18n.baseText('workflowSettings.redactionFloorNotice')
}}</span>
<span v-else
>{{ i18n.baseText('workflowSettings.redactionPermissionNotice') }}
@ -1361,8 +1393,8 @@ onBeforeUnmount(() => {
size="xsmall"
style="opacity: 1"
:data-test-id="
manualRedactionLockReason === 'enforcement'
? 'workflow-settings-redaction-enforced-lock'
manualRedactionLockReason === 'floor'
? 'workflow-settings-redaction-floor-lock'
: undefined
"
/>
@ -1394,8 +1426,8 @@ onBeforeUnmount(() => {
placement="top"
>
<template #content>
<span v-if="manualRedactionLockReason === 'enforcement'">{{
i18n.baseText('workflowSettings.redactionEnforcementNotice')
<span v-if="manualRedactionLockReason === 'floor'">{{
i18n.baseText('workflowSettings.redactionFloorNotice')
}}</span>
<span v-else-if="isManualRedactionLocked"
>{{ i18n.baseText('workflowSettings.redactionPermissionNotice') }}