mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
refactor(core): Store redaction enforcement as a floor enum natively (#31629)
This commit is contained in:
parent
6e0517e134
commit
d4bb69aee2
|
|
@ -1,15 +1,3 @@
|
|||
import { z } from 'zod';
|
||||
import type { RedactionFloor } from './redaction-enforcement-floor';
|
||||
|
||||
export const redactionEnforcementSettingsSchema = z.object({
|
||||
enforced: z.boolean(),
|
||||
manual: z.boolean(),
|
||||
production: z.boolean(),
|
||||
});
|
||||
|
||||
export type RedactionEnforcementSettings = z.infer<typeof redactionEnforcementSettingsSchema>;
|
||||
|
||||
export const REDACTION_ENFORCEMENT_DEFAULTS: RedactionEnforcementSettings = {
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
};
|
||||
export const REDACTION_FLOOR_DEFAULT: RedactionFloor = 'off';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
|
||||
|
||||
const REDACTION_ENFORCEMENT_KEY = 'redaction.enforcement';
|
||||
|
||||
type RedactionFloor = 'off' | 'production' | 'all';
|
||||
|
||||
const FLOORS: readonly RedactionFloor[] = ['off', 'production', 'all'];
|
||||
|
||||
interface BooleanSettings {
|
||||
enforced: boolean;
|
||||
manual: boolean;
|
||||
production: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the stored redaction enforcement setting from the boolean triple
|
||||
* `{ enforced, manual, production }` to the floor enum `'off' | 'production' | 'all'`.
|
||||
*
|
||||
* Only the three combinations the API could produce map to a floor; every other
|
||||
* (impossible) combination normalises up to the strictest `'all'` — never weaker
|
||||
* than what was stored:
|
||||
* - `enforced: false` -> 'off'
|
||||
* - `{ enforced: true, manual: false, production: true }` -> 'production'
|
||||
* - `{ enforced: true, manual: true, production: true }` -> 'all'
|
||||
* - anything else (impossible tuple, unparseable, malformed) -> 'all'
|
||||
*
|
||||
* The value is stored as a JSON-encoded string (e.g. the literal `"off"`), matching
|
||||
* how the service serialises it, so the read path stays unchanged.
|
||||
*
|
||||
* Irreversible: collapsing three booleans into one enum is lossy, so there is no
|
||||
* faithful `down()`.
|
||||
*
|
||||
* Compatible with SQLite and PostgreSQL.
|
||||
*/
|
||||
export class MigrateRedactionEnforcementToFloor1784000000025 implements IrreversibleMigration {
|
||||
async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
|
||||
const settingsTable = escape.tableName('settings');
|
||||
const keyCol = escape.columnName('key');
|
||||
const valueCol = escape.columnName('value');
|
||||
|
||||
const rows: Array<{ value: string }> = await runQuery(
|
||||
`SELECT ${valueCol} AS value FROM ${settingsTable} WHERE ${keyCol} = :key;`,
|
||||
{ key: REDACTION_ENFORCEMENT_KEY },
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.info(
|
||||
`[${migrationName}] No stored redaction enforcement setting found, nothing to migrate`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const floor = this.toFloor(rows[0].value);
|
||||
const serialized = JSON.stringify(floor);
|
||||
|
||||
await runQuery(`UPDATE ${settingsTable} SET ${valueCol} = :value WHERE ${keyCol} = :key;`, {
|
||||
value: serialized,
|
||||
key: REDACTION_ENFORCEMENT_KEY,
|
||||
});
|
||||
|
||||
logger.info(`[${migrationName}] Migrated redaction enforcement setting to floor='${floor}'`);
|
||||
}
|
||||
|
||||
private toFloor(raw: string): RedactionFloor {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = jsonParse<unknown>(raw);
|
||||
} catch {
|
||||
// Unparseable value — fall back to the strictest floor.
|
||||
return 'all';
|
||||
}
|
||||
|
||||
// Already migrated (e.g. a re-run): leave the existing floor untouched.
|
||||
if (typeof parsed === 'string' && FLOORS.includes(parsed as RedactionFloor)) {
|
||||
return parsed as RedactionFloor;
|
||||
}
|
||||
|
||||
if (!this.isBooleanSettings(parsed)) return 'all';
|
||||
|
||||
// Map only the API-producible combinations; every other (impossible)
|
||||
// enforced combination — including `{ manual: false, production: false }`
|
||||
// and `{ manual: true, production: false }` — normalises up to 'all'.
|
||||
if (!parsed.enforced) return 'off';
|
||||
if (!parsed.manual && parsed.production) return 'production';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
private isBooleanSettings(value: unknown): value is BooleanSettings {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.enforced === 'boolean' &&
|
||||
typeof candidate.manual === 'boolean' &&
|
||||
typeof candidate.production === 'boolean'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -200,6 +200,7 @@ import { CreateAgentTaskDefinitionTable1784000000021 } from '../common/178400000
|
|||
import { AddSubAgentLinkageToAgentExecutionThreads1784000000022 } from '../common/1784000000022-AddSubAgentLinkageToAgentExecutionThreads';
|
||||
import { CreateInstanceAiMcpRegistryConnectionTable1784000000023 } from '../common/1784000000023-CreateInstanceAiMcpRegistryConnectionTable';
|
||||
import { AddResourceToOAuthAuthorizationCodes1784000000024 } from '../common/1784000000024-AddResourceToOAuthAuthorizationCodes';
|
||||
import { MigrateRedactionEnforcementToFloor1784000000025 } from '../common/1784000000025-MigrateRedactionEnforcementToFloor';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
|
|
@ -405,4 +406,5 @@ export const postgresMigrations: Migration[] = [
|
|||
AddSubAgentLinkageToAgentExecutionThreads1784000000022,
|
||||
CreateInstanceAiMcpRegistryConnectionTable1784000000023,
|
||||
AddResourceToOAuthAuthorizationCodes1784000000024,
|
||||
MigrateRedactionEnforcementToFloor1784000000025,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ import { CreateInstanceAiMcpRegistryConnectionTable1784000000023 } from '../comm
|
|||
import { AddResourceToOAuthAuthorizationCodes1784000000024 } from '../common/1784000000024-AddResourceToOAuthAuthorizationCodes';
|
||||
import type { Migration } from '../migration-types';
|
||||
import { CreateAgentTaskDefinitionTable1784000000021 } from './1784000000021-CreateAgentTaskDefinitionTable';
|
||||
import { MigrateRedactionEnforcementToFloor1784000000025 } from '../common/1784000000025-MigrateRedactionEnforcementToFloor';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
|
|
@ -391,6 +392,7 @@ const sqliteMigrations: Migration[] = [
|
|||
AddSubAgentLinkageToAgentExecutionThreads1784000000022,
|
||||
CreateInstanceAiMcpRegistryConnectionTable1784000000023,
|
||||
AddResourceToOAuthAuthorizationCodes1784000000024,
|
||||
MigrateRedactionEnforcementToFloor1784000000025,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type { Response } from 'express';
|
|||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service';
|
||||
import { floorToSettings, settingsToFloor } from '@/modules/redaction/redaction-enforcement-mapper';
|
||||
import { isRedactionEnforcementEnabled } from '@/modules/redaction/redaction-enforcement.feature-flag';
|
||||
import { SecuritySettingsService } from '@/services/security-settings.service';
|
||||
|
||||
|
|
@ -46,11 +45,7 @@ export class SecuritySettingsController {
|
|||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
// API surface uses a single `floor` enum, while the service stores the
|
||||
// three booleans the cache layer was built around. Translate at the boundary.
|
||||
const redactionEnforcement = redactionSettings
|
||||
? { floor: settingsToFloor(redactionSettings) }
|
||||
: undefined;
|
||||
const redactionEnforcement = redactionSettings ? { floor: redactionSettings } : undefined;
|
||||
|
||||
return {
|
||||
...settings,
|
||||
|
|
@ -96,13 +91,9 @@ export class SecuritySettingsController {
|
|||
|
||||
if (dto.redactionEnforcement !== undefined && isRedactionEnforcementEnabled()) {
|
||||
const before = await this.instanceRedactionEnforcementService.get();
|
||||
const after = floorToSettings(dto.redactionEnforcement.floor);
|
||||
updatedSettings.redactionEnforcement = { floor: dto.redactionEnforcement.floor };
|
||||
if (
|
||||
before.enforced !== after.enforced ||
|
||||
before.manual !== after.manual ||
|
||||
before.production !== after.production
|
||||
) {
|
||||
const after = dto.redactionEnforcement.floor;
|
||||
updatedSettings.redactionEnforcement = { floor: after };
|
||||
if (before !== after) {
|
||||
await this.instanceRedactionEnforcementService.set(after);
|
||||
this.eventService.emit('redaction-enforcement-updated', {
|
||||
user: {
|
||||
|
|
|
|||
|
|
@ -2668,8 +2668,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
lastName: 'Admin',
|
||||
role: { slug: 'global:owner' },
|
||||
},
|
||||
before: { enforced: false, manual: false, production: false },
|
||||
after: { enforced: true, manual: false, production: true },
|
||||
before: 'off',
|
||||
after: 'production',
|
||||
};
|
||||
|
||||
eventService.emit('redaction-enforcement-updated', event);
|
||||
|
|
@ -2682,8 +2682,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
_firstName: 'Seventh',
|
||||
_lastName: 'Admin',
|
||||
globalRole: 'global:owner',
|
||||
before: { enforced: false, manual: false, production: false },
|
||||
after: { enforced: true, manual: false, production: true },
|
||||
before: 'off',
|
||||
after: 'production',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -2697,8 +2697,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
lastName: 'Admin',
|
||||
role: { slug: 'global:owner' },
|
||||
},
|
||||
before: { enforced: true, manual: true, production: true },
|
||||
after: { enforced: true, manual: false, production: true },
|
||||
before: 'all',
|
||||
after: 'production',
|
||||
};
|
||||
|
||||
eventService.emit('redaction-enforcement-updated', event);
|
||||
|
|
@ -2711,13 +2711,13 @@ describe('LogStreamingEventRelay', () => {
|
|||
_firstName: 'Seventh',
|
||||
_lastName: 'Admin',
|
||||
globalRole: 'global:owner',
|
||||
before: { enforced: true, manual: true, production: true },
|
||||
after: { enforced: true, manual: false, production: true },
|
||||
before: 'all',
|
||||
after: 'production',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log `redaction-enforcement.updated` for upgrade to `all` (full-tuple passthrough)', () => {
|
||||
it('should log `redaction-enforcement.updated` for upgrade to `all`', () => {
|
||||
const event: RelayEventMap['redaction-enforcement-updated'] = {
|
||||
user: {
|
||||
id: 'user404',
|
||||
|
|
@ -2726,8 +2726,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
lastName: 'Admin',
|
||||
role: { slug: 'global:owner' },
|
||||
},
|
||||
before: { enforced: true, manual: false, production: true },
|
||||
after: { enforced: true, manual: true, production: true },
|
||||
before: 'production',
|
||||
after: 'all',
|
||||
};
|
||||
|
||||
eventService.emit('redaction-enforcement-updated', event);
|
||||
|
|
@ -2740,8 +2740,8 @@ describe('LogStreamingEventRelay', () => {
|
|||
_firstName: 'Seventh',
|
||||
_lastName: 'Admin',
|
||||
globalRole: 'global:owner',
|
||||
before: { enforced: true, manual: false, production: true },
|
||||
after: { enforced: true, manual: true, production: true },
|
||||
before: 'production',
|
||||
after: 'all',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
AuthenticationMethod,
|
||||
ProjectRelation,
|
||||
RedactionEnforcementSettings,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticationMethod, ProjectRelation, RedactionFloor } from '@n8n/api-types';
|
||||
import type { AuthProviderType, User, IWorkflowDb } from '@n8n/db';
|
||||
import type {
|
||||
CancellationReason,
|
||||
|
|
@ -990,8 +986,8 @@ export type RelayEventMap = {
|
|||
|
||||
'redaction-enforcement-updated': {
|
||||
user: UserLike;
|
||||
before: RedactionEnforcementSettings;
|
||||
after: RedactionEnforcementSettings;
|
||||
before: RedactionFloor;
|
||||
after: RedactionFloor;
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { REDACTION_ENFORCEMENT_DEFAULTS } from '@n8n/api-types';
|
||||
import { REDACTION_FLOOR_DEFAULT, type RedactionFloor } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { Settings, SettingsRepository } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
|
@ -64,8 +64,8 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
describe('with feature flag off', () => {
|
||||
beforeEach(() => disableFlag());
|
||||
|
||||
it('returns defaults without touching cache or repository', async () => {
|
||||
await expect(service.get()).resolves.toEqual(REDACTION_ENFORCEMENT_DEFAULTS);
|
||||
it('returns the default floor without touching cache or repository', async () => {
|
||||
await expect(service.get()).resolves.toBe(REDACTION_FLOOR_DEFAULT);
|
||||
expect(cacheService.get).not.toHaveBeenCalled();
|
||||
expect(findByKey).not.toHaveBeenCalled();
|
||||
expect(cacheService.set).not.toHaveBeenCalled();
|
||||
|
|
@ -75,17 +75,17 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
describe('with feature flag on', () => {
|
||||
beforeEach(() => enableFlag());
|
||||
|
||||
it('returns parsed value from cache on hit without reading DB', async () => {
|
||||
const cached = { enforced: true, manual: true, production: true };
|
||||
it('returns parsed floor from cache on hit without reading DB', async () => {
|
||||
const cached: RedactionFloor = 'all';
|
||||
simulateCacheHit(JSON.stringify(cached));
|
||||
|
||||
await expect(service.get()).resolves.toEqual(cached);
|
||||
await expect(service.get()).resolves.toBe(cached);
|
||||
expect(findByKey).not.toHaveBeenCalled();
|
||||
expect(cacheService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to DB, returns row, and backfills cache on cache miss', async () => {
|
||||
const stored = { enforced: true, manual: false, production: true };
|
||||
const stored: RedactionFloor = 'production';
|
||||
simulateCacheMiss();
|
||||
findByKey.mockResolvedValueOnce(
|
||||
mock<Settings>({
|
||||
|
|
@ -95,26 +95,23 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
await expect(service.get()).resolves.toEqual(stored);
|
||||
await expect(service.get()).resolves.toBe(stored);
|
||||
expect(findByKey).toHaveBeenCalledWith(KEY);
|
||||
expect(cacheService.set).toHaveBeenCalledWith(KEY, JSON.stringify(stored));
|
||||
});
|
||||
|
||||
it('returns defaults and backfills cache when no DB row exists', async () => {
|
||||
it('returns the default floor and backfills cache when no DB row exists', async () => {
|
||||
simulateCacheMiss();
|
||||
findByKey.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.get()).resolves.toEqual(REDACTION_ENFORCEMENT_DEFAULTS);
|
||||
expect(cacheService.set).toHaveBeenCalledWith(
|
||||
KEY,
|
||||
JSON.stringify(REDACTION_ENFORCEMENT_DEFAULTS),
|
||||
);
|
||||
await expect(service.get()).resolves.toBe(REDACTION_FLOOR_DEFAULT);
|
||||
expect(cacheService.set).toHaveBeenCalledWith(KEY, JSON.stringify(REDACTION_FLOOR_DEFAULT));
|
||||
});
|
||||
|
||||
it('returns defaults when cache has invalid JSON, and logs a warning', async () => {
|
||||
it('returns the default floor when cache has invalid JSON, and logs a warning', async () => {
|
||||
simulateCacheHit('not-json');
|
||||
|
||||
await expect(service.get()).resolves.toEqual(REDACTION_ENFORCEMENT_DEFAULTS);
|
||||
await expect(service.get()).resolves.toBe(REDACTION_FLOOR_DEFAULT);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to parse redaction enforcement setting JSON',
|
||||
expect.objectContaining({ source: 'cache' }),
|
||||
|
|
@ -122,25 +119,22 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
expect(findByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns defaults and backfills cache when DB value has an invalid shape, and logs a warning', async () => {
|
||||
it('returns the default floor and backfills cache when DB value has an invalid shape, and logs a warning', async () => {
|
||||
simulateCacheMiss();
|
||||
findByKey.mockResolvedValueOnce(
|
||||
mock<Settings>({
|
||||
key: KEY,
|
||||
value: JSON.stringify({ enforced: 'yes', manual: false, production: true }),
|
||||
value: JSON.stringify('bogus'),
|
||||
loadOnStartup: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.get()).resolves.toEqual(REDACTION_ENFORCEMENT_DEFAULTS);
|
||||
await expect(service.get()).resolves.toBe(REDACTION_FLOOR_DEFAULT);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Redaction enforcement setting has an invalid shape',
|
||||
expect.objectContaining({ source: 'database' }),
|
||||
);
|
||||
expect(cacheService.set).toHaveBeenCalledWith(
|
||||
KEY,
|
||||
JSON.stringify(REDACTION_ENFORCEMENT_DEFAULTS),
|
||||
);
|
||||
expect(cacheService.set).toHaveBeenCalledWith(KEY, JSON.stringify(REDACTION_FLOOR_DEFAULT));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -150,9 +144,7 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
beforeEach(() => disableFlag());
|
||||
|
||||
it('throws OperationalError without touching cache or repository', async () => {
|
||||
await expect(
|
||||
service.set({ enforced: true, manual: true, production: true }),
|
||||
).rejects.toThrow(OperationalError);
|
||||
await expect(service.set('all')).rejects.toThrow(OperationalError);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(cacheService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -161,8 +153,8 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
describe('with feature flag on', () => {
|
||||
beforeEach(() => enableFlag());
|
||||
|
||||
it('upserts the setting and writes the same serialized value to cache', async () => {
|
||||
const next = { enforced: true, manual: false, production: true };
|
||||
it('upserts the floor and writes the same serialized value to cache', async () => {
|
||||
const next: RedactionFloor = 'production';
|
||||
|
||||
await service.set(next);
|
||||
|
||||
|
|
@ -174,11 +166,7 @@ describe('InstanceRedactionEnforcementService', () => {
|
|||
});
|
||||
|
||||
it('rejects invalid input with a UserError and logs validation issues', async () => {
|
||||
const invalid = { enforced: true, manual: 'yes', production: true } as unknown as {
|
||||
enforced: boolean;
|
||||
manual: boolean;
|
||||
production: boolean;
|
||||
};
|
||||
const invalid = 'bogus' as unknown as RedactionFloor;
|
||||
|
||||
await expect(service.set(invalid)).rejects.toThrow(UserError);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe('RedactionContextHook integration with establishExecutionContext', () =
|
|||
floor: 'off' | 'production' | 'all',
|
||||
workflowPolicy?: WorkflowSettings.RedactionPolicy,
|
||||
) => {
|
||||
enforcementService.getFloor.mockResolvedValue(floor);
|
||||
enforcementService.get.mockResolvedValue(floor);
|
||||
|
||||
const workflow = buildWorkflow(workflowPolicy);
|
||||
const runExecutionData = buildRunExecutionData();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('RedactionContextHook', () => {
|
|||
});
|
||||
|
||||
const setFloor = (floor: RedactionFloor) => {
|
||||
service.getFloor.mockResolvedValue(floor);
|
||||
service.get.mockResolvedValue(floor);
|
||||
};
|
||||
|
||||
const expectChannels = (production: boolean, manual: boolean) => ({
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { floorToSettings, settingsToFloor } from '../redaction-enforcement-mapper';
|
||||
|
||||
describe('redaction-enforcement-mapper', () => {
|
||||
describe('floorToSettings', () => {
|
||||
it('maps off → all booleans false', () => {
|
||||
expect(floorToSettings('off')).toEqual({
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps production → enforced + production only', () => {
|
||||
expect(floorToSettings('production')).toEqual({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps all → enforced + manual + production', () => {
|
||||
expect(floorToSettings('all')).toEqual({
|
||||
enforced: true,
|
||||
manual: true,
|
||||
production: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsToFloor', () => {
|
||||
it('maps {enforced: false} → off regardless of other flags', () => {
|
||||
expect(settingsToFloor({ enforced: false, manual: false, production: false })).toBe('off');
|
||||
expect(settingsToFloor({ enforced: false, manual: true, production: true })).toBe('off');
|
||||
});
|
||||
|
||||
it('maps {enforced, production} → production', () => {
|
||||
expect(settingsToFloor({ enforced: true, manual: false, production: true })).toBe(
|
||||
'production',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps {enforced, manual, production} → all', () => {
|
||||
expect(settingsToFloor({ enforced: true, manual: true, production: true })).toBe('all');
|
||||
});
|
||||
|
||||
it('normalizes upward when manual is true but production is false', () => {
|
||||
// Combination unreachable via the API; defensive fallback returns the
|
||||
// stricter floor so callers never see a weaker floor than what is stored.
|
||||
expect(settingsToFloor({ enforced: true, manual: true, production: false })).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it.each(['off', 'production', 'all'] as const)(
|
||||
'floor → settings → floor preserves %s',
|
||||
(floor) => {
|
||||
expect(settingsToFloor(floorToSettings(floor))).toBe(floor);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { RedactionEnforcementSettings, RedactionFloor } from '@n8n/api-types';
|
||||
import type { RedactionFloor } from '@n8n/api-types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { WorkflowSettings } from 'n8n-workflow';
|
||||
|
||||
import { floorToSettings } from '../redaction-enforcement-mapper';
|
||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||
|
||||
import type { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service';
|
||||
|
|
@ -11,8 +10,7 @@ import { RedactionEnforcementService } from '../redaction-enforcement.service';
|
|||
describe('RedactionEnforcementService', () => {
|
||||
function createService(floor: RedactionFloor) {
|
||||
const instanceRedactionEnforcementService = mock<InstanceRedactionEnforcementService>();
|
||||
const settings: RedactionEnforcementSettings = floorToSettings(floor);
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue(settings);
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue(floor);
|
||||
return new RedactionEnforcementService(instanceRedactionEnforcementService);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
REDACTION_ENFORCEMENT_DEFAULTS,
|
||||
redactionEnforcementSettingsSchema,
|
||||
type RedactionEnforcementSettings,
|
||||
type RedactionFloor,
|
||||
} from '@n8n/api-types';
|
||||
import { REDACTION_FLOOR_DEFAULT, redactionFloorSchema, type RedactionFloor } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { SettingsRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
|
@ -11,7 +6,6 @@ import { OperationalError, UserError } from 'n8n-workflow';
|
|||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { settingsToFloor } from './redaction-enforcement-mapper';
|
||||
import { isRedactionEnforcementEnabled } from './redaction-enforcement.feature-flag';
|
||||
|
||||
const KEY = 'redaction.enforcement';
|
||||
|
|
@ -24,36 +18,31 @@ export class InstanceRedactionEnforcementService {
|
|||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async get(): Promise<RedactionEnforcementSettings> {
|
||||
if (!isRedactionEnforcementEnabled()) return REDACTION_ENFORCEMENT_DEFAULTS;
|
||||
/**
|
||||
* Resolves the instance redaction floor. Returns `'off'` when enforcement is
|
||||
* disabled or no value is stored. The floor is stored as the enum directly,
|
||||
* so no translation is needed.
|
||||
*/
|
||||
async get(): Promise<RedactionFloor> {
|
||||
if (!isRedactionEnforcementEnabled()) return REDACTION_FLOOR_DEFAULT;
|
||||
return await this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the instance redaction floor as a `RedactionFloor` enum.
|
||||
* Returns `'off'` when enforcement is disabled. Normalizes any stored
|
||||
* setting upward via `settingsToFloor`, so the floor never reports a
|
||||
* weaker level than what is stored.
|
||||
*/
|
||||
async getFloor(): Promise<RedactionFloor> {
|
||||
return settingsToFloor(await this.get());
|
||||
}
|
||||
|
||||
private async load(): Promise<RedactionEnforcementSettings> {
|
||||
private async load(): Promise<RedactionFloor> {
|
||||
const raw = await this.cacheService.get<string>(KEY, {
|
||||
refreshFn: async () => await this.loadFromDatabase(),
|
||||
});
|
||||
|
||||
if (raw === undefined) return REDACTION_ENFORCEMENT_DEFAULTS;
|
||||
return this.parseStoredValue(raw, 'cache') ?? REDACTION_ENFORCEMENT_DEFAULTS;
|
||||
if (raw === undefined) return REDACTION_FLOOR_DEFAULT;
|
||||
return this.parseStoredValue(raw, 'cache') ?? REDACTION_FLOOR_DEFAULT;
|
||||
}
|
||||
|
||||
async set(next: RedactionEnforcementSettings): Promise<void> {
|
||||
async set(next: RedactionFloor): Promise<void> {
|
||||
if (!isRedactionEnforcementEnabled()) {
|
||||
throw new OperationalError('Redaction enforcement is not enabled on this instance');
|
||||
}
|
||||
|
||||
const result = redactionEnforcementSettingsSchema.safeParse(next);
|
||||
const result = redactionFloorSchema.safeParse(next);
|
||||
if (!result.success) {
|
||||
this.logger.warn('Invalid redaction enforcement settings payload', {
|
||||
issues: result.error.issues,
|
||||
|
|
@ -74,15 +63,12 @@ export class InstanceRedactionEnforcementService {
|
|||
const row = await this.settingsRepository.findByKey(KEY);
|
||||
const value =
|
||||
row?.value !== undefined
|
||||
? (this.parseStoredValue(row.value, 'database') ?? REDACTION_ENFORCEMENT_DEFAULTS)
|
||||
: REDACTION_ENFORCEMENT_DEFAULTS;
|
||||
? (this.parseStoredValue(row.value, 'database') ?? REDACTION_FLOOR_DEFAULT)
|
||||
: REDACTION_FLOOR_DEFAULT;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
private parseStoredValue(
|
||||
raw: string,
|
||||
source: 'cache' | 'database',
|
||||
): RedactionEnforcementSettings | undefined {
|
||||
private parseStoredValue(raw: string, source: 'cache' | 'database'): RedactionFloor | undefined {
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
|
|
@ -94,7 +80,7 @@ export class InstanceRedactionEnforcementService {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const result = redactionEnforcementSettingsSchema.safeParse(parsedJson);
|
||||
const result = redactionFloorSchema.safeParse(parsedJson);
|
||||
if (!result.success) {
|
||||
this.logger.warn('Redaction enforcement setting has an invalid shape', {
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export class RedactionContextHook implements IContextEstablishmentHook {
|
|||
* A workflow can be equal to or stricter than the floor, never weaker.
|
||||
*/
|
||||
async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
const floor = await this.instanceRedactionEnforcementService.getFloor();
|
||||
const floor = await this.instanceRedactionEnforcementService.get();
|
||||
const workflow = policyToChannels(options.workflow.settings?.redactionPolicy ?? 'none');
|
||||
|
||||
const floorEnforcesProduction = floor !== 'off';
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import type { RedactionEnforcementSettings, RedactionFloor } from '@n8n/api-types';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { assertNever } from '@/utils';
|
||||
|
||||
export function floorToSettings(floor: RedactionFloor): RedactionEnforcementSettings {
|
||||
switch (floor) {
|
||||
case 'off':
|
||||
return { enforced: false, manual: false, production: false };
|
||||
case 'production':
|
||||
return { enforced: true, manual: false, production: true };
|
||||
case 'all':
|
||||
return { enforced: true, manual: true, production: true };
|
||||
default:
|
||||
assertNever(floor);
|
||||
throw new UnexpectedError(`Unknown redaction floor: ${String(floor)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function settingsToFloor(settings: RedactionEnforcementSettings): RedactionFloor {
|
||||
if (!settings.enforced) return 'off';
|
||||
// `manual` implies the strictest stored level reachable via this API.
|
||||
// Combinations the API cannot produce (e.g. `{manual: true, production: false}`)
|
||||
// normalize upward — never report a weaker floor than what is stored.
|
||||
if (settings.manual) return 'all';
|
||||
return 'production';
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import type { WorkflowSettings } from 'n8n-workflow';
|
||||
|
||||
import { settingsToFloor } from './redaction-enforcement-mapper';
|
||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||
|
||||
import { InstanceRedactionEnforcementService } from './instance-redaction-enforcement.service';
|
||||
|
|
@ -22,11 +21,6 @@ export class RedactionEnforcementService {
|
|||
private readonly instanceRedactionEnforcementService: InstanceRedactionEnforcementService,
|
||||
) {}
|
||||
|
||||
private async getFloor() {
|
||||
const settings = await this.instanceRedactionEnforcementService.get();
|
||||
return settingsToFloor(settings);
|
||||
}
|
||||
|
||||
async assertPolicyChangeAllowed(
|
||||
currentPolicy: WorkflowSettings.RedactionPolicy | undefined,
|
||||
incomingPolicy: WorkflowSettings.RedactionPolicy | undefined,
|
||||
|
|
@ -36,7 +30,8 @@ export class RedactionEnforcementService {
|
|||
// Unchanged: preserve legacy below-floor state (no retroactive application).
|
||||
if (incomingPolicy === currentPolicy) return;
|
||||
|
||||
const floor = await this.getFloor();
|
||||
// The floor is stored as the enum directly, so no translation is needed.
|
||||
const floor = await this.instanceRedactionEnforcementService.get();
|
||||
if (!policyMeetsFloor(incomingPolicy, floor)) {
|
||||
throw new UnprocessableRequestError(REDACTION_FLOOR_VIOLATION_MESSAGE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,11 +256,7 @@ describe('SecuritySettingsController', () => {
|
|||
|
||||
it('should return redactionEnforcement.floor = "off" by default when flag is on', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('off');
|
||||
|
||||
const response = await ownerAgent.get('/settings/security').expect(200);
|
||||
|
||||
|
|
@ -268,26 +264,18 @@ describe('SecuritySettingsController', () => {
|
|||
expect(instanceRedactionEnforcementService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should translate stored {enforced, production} to floor = "production"', async () => {
|
||||
it('should return stored floor = "production"', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('production');
|
||||
|
||||
const response = await ownerAgent.get('/settings/security').expect(200);
|
||||
|
||||
expect(response.body.data.redactionEnforcement).toEqual({ floor: 'production' });
|
||||
});
|
||||
|
||||
it('should translate stored {enforced, manual, production} to floor = "all"', async () => {
|
||||
it('should return stored floor = "all"', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: true,
|
||||
manual: true,
|
||||
production: true,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('all');
|
||||
|
||||
const response = await ownerAgent.get('/settings/security').expect(200);
|
||||
|
||||
|
|
@ -297,11 +285,7 @@ describe('SecuritySettingsController', () => {
|
|||
|
||||
describe('POST /settings/security', () => {
|
||||
beforeEach(() => {
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('off');
|
||||
});
|
||||
|
||||
it('should ignore redactionEnforcement when feature flag is off', async () => {
|
||||
|
|
@ -331,11 +315,7 @@ describe('SecuritySettingsController', () => {
|
|||
data: { redactionEnforcement: { floor: 'production' } },
|
||||
});
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledTimes(1);
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith('production');
|
||||
});
|
||||
|
||||
it('should persist redactionEnforcement.floor = "all" when flag is on', async () => {
|
||||
|
|
@ -347,20 +327,12 @@ describe('SecuritySettingsController', () => {
|
|||
.send({ redactionEnforcement: { floor: 'all' } })
|
||||
.expect(200);
|
||||
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith({
|
||||
enforced: true,
|
||||
manual: true,
|
||||
production: true,
|
||||
});
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it('should persist redactionEnforcement.floor = "off" when flag is on', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('production');
|
||||
instanceRedactionEnforcementService.set.mockResolvedValue(undefined);
|
||||
|
||||
await ownerAgent
|
||||
|
|
@ -368,11 +340,7 @@ describe('SecuritySettingsController', () => {
|
|||
.send({ redactionEnforcement: { floor: 'off' } })
|
||||
.expect(200);
|
||||
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith({
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
});
|
||||
expect(instanceRedactionEnforcementService.set).toHaveBeenCalledWith('off');
|
||||
});
|
||||
|
||||
it('should update personalSpace and redactionEnforcement together', async () => {
|
||||
|
|
@ -412,11 +380,7 @@ describe('SecuritySettingsController', () => {
|
|||
describe('audit event emission', () => {
|
||||
it('should emit `redaction-enforcement-updated` with before/after when settings change', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: false,
|
||||
manual: false,
|
||||
production: false,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('off');
|
||||
instanceRedactionEnforcementService.set.mockResolvedValue(undefined);
|
||||
|
||||
await ownerAgent
|
||||
|
|
@ -428,19 +392,15 @@ describe('SecuritySettingsController', () => {
|
|||
'redaction-enforcement-updated',
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({ id: expect.any(String) }),
|
||||
before: { enforced: false, manual: false, production: false },
|
||||
after: { enforced: true, manual: false, production: true },
|
||||
before: 'off',
|
||||
after: 'production',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit `redaction-enforcement-updated` with before/after when settings are disabled', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('production');
|
||||
instanceRedactionEnforcementService.set.mockResolvedValue(undefined);
|
||||
|
||||
await ownerAgent
|
||||
|
|
@ -452,19 +412,15 @@ describe('SecuritySettingsController', () => {
|
|||
'redaction-enforcement-updated',
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({ id: expect.any(String) }),
|
||||
before: { enforced: true, manual: false, production: true },
|
||||
after: { enforced: false, manual: false, production: false },
|
||||
before: 'production',
|
||||
after: 'off',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit when save is idempotent', async () => {
|
||||
enableRedactionFlag();
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue({
|
||||
enforced: true,
|
||||
manual: false,
|
||||
production: true,
|
||||
});
|
||||
instanceRedactionEnforcementService.get.mockResolvedValue('production');
|
||||
instanceRedactionEnforcementService.set.mockResolvedValue(undefined);
|
||||
|
||||
await ownerAgent
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
createTestMigrationContext,
|
||||
initDbUpToMigration,
|
||||
runSingleMigration,
|
||||
type TestMigrationContext,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { DbConnection } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
const MIGRATION_NAME = 'MigrateRedactionEnforcementToFloor1784000000025';
|
||||
const REDACTION_ENFORCEMENT_KEY = 'redaction.enforcement';
|
||||
|
||||
describe('MigrateRedactionEnforcementToFloor Migration', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
dataSource = Container.get(DataSource);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
await context.queryRunner.clearDatabase();
|
||||
await context.queryRunner.release();
|
||||
await initDbUpToMigration(MIGRATION_NAME);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.close();
|
||||
});
|
||||
|
||||
async function insertSetting(context: TestMigrationContext, value: string): Promise<void> {
|
||||
const tableName = context.escape.tableName('settings');
|
||||
const keyCol = context.escape.columnName('key');
|
||||
const valueCol = context.escape.columnName('value');
|
||||
const loadOnStartupCol = context.escape.columnName('loadOnStartup');
|
||||
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${tableName} (${keyCol}, ${valueCol}, ${loadOnStartupCol}) VALUES (:key, :value, :loadOnStartup)`,
|
||||
{ key: REDACTION_ENFORCEMENT_KEY, value, loadOnStartup: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function getStoredValue(context: TestMigrationContext): Promise<string | undefined> {
|
||||
const tableName = context.escape.tableName('settings');
|
||||
const keyCol = context.escape.columnName('key');
|
||||
const valueCol = context.escape.columnName('value');
|
||||
|
||||
const rows: Array<{ value: string }> = await context.runQuery(
|
||||
`SELECT ${valueCol} AS value FROM ${tableName} WHERE ${keyCol} = :key`,
|
||||
{ key: REDACTION_ENFORCEMENT_KEY },
|
||||
);
|
||||
|
||||
return rows[0]?.value;
|
||||
}
|
||||
|
||||
async function migrateValue(stored: string): Promise<string | undefined> {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
await insertSetting(context, stored);
|
||||
await context.queryRunner.release();
|
||||
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
const postContext = createTestMigrationContext(dataSource);
|
||||
const value = await getStoredValue(postContext);
|
||||
await postContext.queryRunner.release();
|
||||
return value;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['{"enforced":false,"manual":false,"production":false}', 'off'],
|
||||
['{"enforced":true,"manual":false,"production":true}', 'production'],
|
||||
['{"enforced":true,"manual":true,"production":true}', 'all'],
|
||||
// Impossible-but-storable combinations normalise up to the strictest 'all'.
|
||||
['{"enforced":true,"manual":true,"production":false}', 'all'],
|
||||
['{"enforced":true,"manual":false,"production":false}', 'all'],
|
||||
])('maps stored boolean shape %s to floor "%s"', async (stored, expected) => {
|
||||
const value = await migrateValue(stored);
|
||||
expect(value).toBe(JSON.stringify(expected));
|
||||
});
|
||||
|
||||
it('falls back to "all" for unparseable JSON', async () => {
|
||||
const value = await migrateValue('not-json');
|
||||
expect(value).toBe(JSON.stringify('all'));
|
||||
});
|
||||
|
||||
it('falls back to "all" for valid JSON with the wrong shape', async () => {
|
||||
const value = await migrateValue('{"unexpected":true}');
|
||||
expect(value).toBe(JSON.stringify('all'));
|
||||
});
|
||||
|
||||
it('leaves an already-migrated floor value untouched', async () => {
|
||||
const value = await migrateValue(JSON.stringify('production'));
|
||||
expect(value).toBe(JSON.stringify('production'));
|
||||
});
|
||||
|
||||
it('does not create a row when none exists', async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
await context.queryRunner.release();
|
||||
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
const postContext = createTestMigrationContext(dataSource);
|
||||
const value = await getStoredValue(postContext);
|
||||
expect(value).toBeUndefined();
|
||||
await postContext.queryRunner.release();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user