Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@n8n/api-types/src/dto/provisioning/config.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export class ProvisioningConfigPatchDto extends Z.class({
scopesInstanceRoleClaimName: z.string().optional().nullable(),
scopesProjectsRolesClaimName: z.string().optional().nullable(),
scopesUseExpressionMapping: z.boolean().optional().nullable(),
deleteProjectRules: z.boolean().optional(),
}) {}
1 change: 1 addition & 0 deletions packages/cli/src/eventbus/event-message-classes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const eventNamesAudit = [
'n8n.audit.role-mapping.rule.created',
'n8n.audit.role-mapping.rule.updated',
'n8n.audit.role-mapping.rule.deleted',
'n8n.audit.role-mapping.rules.bulk-deleted',
] as const;

export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number];
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/events/maps/relay.event-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,12 @@ export type RelayEventMap = {
ruleType: 'instance' | 'project';
};

'role-mapping-rules-bulk-deleted': {
ruleType: 'instance' | 'project';
count: number;
reason: 'strategy-switch';
};

// #endregion

// #region Token exchange
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/events/relays/log-streaming.event-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class LogStreamingEventRelay extends EventRelay {
'role-mapping-rule-created': (event) => this.roleMappingRuleCreated(event),
'role-mapping-rule-updated': (event) => this.roleMappingRuleUpdated(event),
'role-mapping-rule-deleted': (event) => this.roleMappingRuleDeleted(event),
'role-mapping-rules-bulk-deleted': (event) => this.roleMappingRulesBulkDeleted(event),
});
}

Expand Down Expand Up @@ -1140,5 +1141,18 @@ export class LogStreamingEventRelay extends EventRelay {
});
}

private roleMappingRulesBulkDeleted(event: RelayEventMap['role-mapping-rules-bulk-deleted']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.role-mapping.rules.bulk-deleted',
payload: {
msg: {
ruleType: event.ruleType,
count: event.count,
reason: event.reason,
},
},
});
}

// #endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import { type InstanceSettings } from 'n8n-core';
import { type EventService } from '@/events/event.service';
import { type UserService } from '@/services/user.service';
import { type RoleResolverService } from '@/modules/provisioning.ee/role-resolver.service.ee';
import { type RoleMappingRuleService } from '@/modules/provisioning.ee/role-mapping-rule.service.ee';

const globalConfig = mock<GlobalConfig>();
const settingsRepository = mock<SettingsRepository>();
const settingsEntityManager = mock<EntityManager>();
const settingsRepository = mock<SettingsRepository>({ manager: settingsEntityManager });
const userRepository = mock<UserRepository>();
const userService = mock<UserService>();
const entityManager = mock<EntityManager>();
Expand All @@ -39,6 +41,7 @@ const roleRepository = mock<RoleRepository>();
const instanceSettings = mock<InstanceSettings>();
const roleMappingRuleRepository = mock<RoleMappingRuleRepository>();
const roleResolverService = mock<RoleResolverService>();
const roleMappingRuleService = mock<RoleMappingRuleService>();

const provisioningService = new ProvisioningService(
eventService,
Expand All @@ -54,6 +57,7 @@ const provisioningService = new ProvisioningService(
instanceSettings,
roleMappingRuleRepository,
roleResolverService,
roleMappingRuleService,
);

describe('ProvisioningService', () => {
Expand All @@ -63,6 +67,11 @@ describe('ProvisioningService', () => {
// @ts-expect-error Mock
await cb(entityManager);
});
settingsEntityManager.transaction.mockImplementation(async (cb) => {
// @ts-expect-error Mock
await cb(settingsEntityManager);
});
settingsEntityManager.getRepository.mockReturnValue(settingsRepository);
});

const provisioningConfigDto: ProvisioningConfigDto = {
Expand Down Expand Up @@ -618,18 +627,34 @@ describe('ProvisioningService', () => {
});

describe('patchConfig', () => {
const stubGetConfigs = (current: ProvisioningConfigDto, next: ProvisioningConfigDto) => {
provisioningService.getConfig = jest
.fn()
.mockResolvedValueOnce(current)
.mockResolvedValueOnce(next);
provisioningService.loadConfig = jest.fn().mockResolvedValue(next);
};

let originStateLoadConfig: typeof provisioningService.loadConfig;
let originStateGetConfig: typeof provisioningService.getConfig;

beforeEach(() => {
originStateLoadConfig = provisioningService.loadConfig;
originStateGetConfig = provisioningService.getConfig;
});

afterEach(() => {
provisioningService.loadConfig = originStateLoadConfig;
provisioningService.getConfig = originStateGetConfig;
});

it('should patch the provisioning config, sending out pubsub updates for other nodes to reload in multi-main setup', async () => {
(instanceSettings as any).isMultiMain = true;
const originStateLoadConfig = provisioningService.loadConfig;
const originStateGetConfig = provisioningService.getConfig;

provisioningService.getConfig = jest
.fn()
.mockResolvedValueOnce(provisioningConfigDto)
.mockResolvedValueOnce({ ...provisioningConfigDto, scopesProvisionInstanceRole: false });
provisioningService.loadConfig = jest
.fn()
.mockResolvedValue({ ...provisioningConfigDto, scopesProvisionInstanceRole: false });
stubGetConfigs(provisioningConfigDto, {
...provisioningConfigDto,
scopesProvisionInstanceRole: false,
});

const config = await provisioningService.patchConfig({ scopesProvisionInstanceRole: false });
expect(config).toEqual({ ...provisioningConfigDto, scopesProvisionInstanceRole: false });
Expand All @@ -640,9 +665,205 @@ describe('ProvisioningService', () => {
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-sso-provisioning-configuration',
});
});

provisioningService.loadConfig = originStateLoadConfig;
provisioningService.getConfig = originStateGetConfig;
it('should wrap settings upsert and project rule cleanup in a single transaction', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionProjectRoles: true,
scopesUseExpressionMapping: false,
};
const next: ProvisioningConfigDto = {
...current,
scopesProvisionProjectRoles: false,
};
stubGetConfigs(current, next);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(2);

await provisioningService.patchConfig({ scopesProvisionProjectRoles: false });

expect(settingsEntityManager.transaction).toHaveBeenCalledTimes(1);
expect(settingsRepository.upsert).toHaveBeenCalledTimes(1);
expect(roleMappingRuleService.deleteAllOfType).toHaveBeenCalledTimes(1);
expect(roleMappingRuleService.deleteAllOfType).toHaveBeenCalledWith(
'project',
settingsEntityManager,
);
});

it('should emit role-mapping-rules-bulk-deleted with the deleted count after commit', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionProjectRoles: true,
scopesUseExpressionMapping: false,
};
const next: ProvisioningConfigDto = { ...current, scopesProvisionProjectRoles: false };
stubGetConfigs(current, next);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(4);

await provisioningService.patchConfig({ scopesProvisionProjectRoles: false });

expect(eventService.emit).toHaveBeenCalledWith('role-mapping-rules-bulk-deleted', {
ruleType: 'project',
count: 4,
reason: 'strategy-switch',
});
});

it('should delete project rules when expression mapping is turned off', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: true,
};
const next: ProvisioningConfigDto = {
...current,
scopesUseExpressionMapping: false,
scopesProvisionInstanceRole: true,
};
stubGetConfigs(current, next);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(1);

await provisioningService.patchConfig({
scopesUseExpressionMapping: false,
scopesProvisionInstanceRole: true,
});

expect(roleMappingRuleService.deleteAllOfType).toHaveBeenCalledWith(
'project',
settingsEntityManager,
);
});

it('should delete project rules when the caller passes explicit deleteProjectRules=true without changing strategy flags', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: true,
};
stubGetConfigs(current, current);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(3);

await provisioningService.patchConfig({ deleteProjectRules: true });

expect(roleMappingRuleService.deleteAllOfType).toHaveBeenCalledWith(
'project',
settingsEntityManager,
);
expect(eventService.emit).toHaveBeenCalledWith('role-mapping-rules-bulk-deleted', {
ruleType: 'project',
count: 3,
reason: 'strategy-switch',
});
});

it('should not touch project rules when the strategy does not drop project-role management', async () => {
(instanceSettings as any).isMultiMain = false;

stubGetConfigs(provisioningConfigDto, {
...provisioningConfigDto,
scopesProvisionInstanceRole: false,
});

await provisioningService.patchConfig({ scopesProvisionInstanceRole: false });

expect(roleMappingRuleService.deleteAllOfType).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalledWith(
'role-mapping-rules-bulk-deleted',
expect.anything(),
);
});

it('should persist deleteProjectRules only as a transient flag, never to settings', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionProjectRoles: true,
};
const next: ProvisioningConfigDto = { ...current, scopesProvisionProjectRoles: false };
stubGetConfigs(current, next);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(0);

await provisioningService.patchConfig({
scopesProvisionProjectRoles: false,
deleteProjectRules: true,
});

const upsertCall = settingsRepository.upsert.mock.calls[0]?.[0] as {
value: string;
};
expect(upsertCall.value).toBeDefined();
expect(JSON.parse(upsertCall.value)).not.toHaveProperty('deleteProjectRules');
});

it('should still broadcast reload-sso-provisioning-configuration after cleanup in multi-main', async () => {
(instanceSettings as any).isMultiMain = true;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionProjectRoles: true,
};
const next: ProvisioningConfigDto = { ...current, scopesProvisionProjectRoles: false };
stubGetConfigs(current, next);
roleMappingRuleService.deleteAllOfType.mockResolvedValue(1);

const transactionInvocationOrder: string[] = [];
settingsEntityManager.transaction.mockImplementation(async (cb) => {
transactionInvocationOrder.push('tx:enter');
// @ts-expect-error Mock
await cb(settingsEntityManager);
transactionInvocationOrder.push('tx:exit');
});
publisher.publishCommand.mockImplementation(async () => {
transactionInvocationOrder.push('pubsub');
});

await provisioningService.patchConfig({ scopesProvisionProjectRoles: false });

expect(roleMappingRuleService.deleteAllOfType).toHaveBeenCalledTimes(1);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-sso-provisioning-configuration',
});
// Pubsub must fire after transaction has fully committed.
expect(transactionInvocationOrder).toEqual(['tx:enter', 'tx:exit', 'pubsub']);
});

it('should not commit settings upsert if the rule cleanup throws inside the transaction', async () => {
(instanceSettings as any).isMultiMain = false;

const current: ProvisioningConfigDto = {
...provisioningConfigDto,
scopesProvisionProjectRoles: true,
};
stubGetConfigs(current, { ...current, scopesProvisionProjectRoles: false });

roleMappingRuleService.deleteAllOfType.mockRejectedValue(new Error('cleanup failed'));
// Simulate real TX behaviour — a throw in the callback rejects the transaction promise,
// and the outer patchConfig must propagate the error.
settingsEntityManager.transaction.mockImplementation(async (cb) => {
// @ts-expect-error Mock
return await cb(settingsEntityManager);
});

await expect(
provisioningService.patchConfig({ scopesProvisionProjectRoles: false }),
).rejects.toThrow('cleanup failed');

expect(eventService.emit).not.toHaveBeenCalledWith(
'role-mapping-rules-bulk-deleted',
expect.anything(),
);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,47 @@ describe('RoleMappingRuleService', () => {
});
});

describe('deleteAllOfType', () => {
it('should delete all rules of the given type using the direct repository when no tx is provided', async () => {
roleMappingRuleRepository.delete.mockResolvedValue({ affected: 3, raw: {} });

const count = await service.deleteAllOfType('project');

expect(roleMappingRuleRepository.delete).toHaveBeenCalledWith({ type: 'project' });
expect(count).toBe(3);
});

it('should use the transactional repository when an EntityManager is provided', async () => {
const txRepoDelete = jest.fn().mockResolvedValue({ affected: 2, raw: {} });
const txRepository = { delete: txRepoDelete };
const getRepository = jest.fn().mockReturnValue(txRepository);
const tx = { getRepository } as unknown as Parameters<typeof service.deleteAllOfType>[1];

const count = await service.deleteAllOfType('instance', tx);

expect(getRepository).toHaveBeenCalled();
expect(txRepoDelete).toHaveBeenCalledWith({ type: 'instance' });
expect(count).toBe(2);
expect(roleMappingRuleRepository.delete).not.toHaveBeenCalled();
});

it('should return 0 when no rows match', async () => {
roleMappingRuleRepository.delete.mockResolvedValue({ affected: 0, raw: {} });

const count = await service.deleteAllOfType('project');

expect(count).toBe(0);
});

it('should treat missing affected count as 0', async () => {
roleMappingRuleRepository.delete.mockResolvedValue({ affected: undefined, raw: {} });

const count = await service.deleteAllOfType('project');

expect(count).toBe(0);
});
});

describe('move', () => {
const updateSpy = jest.fn().mockResolvedValue(undefined);
const transactionSpy = jest.fn().mockImplementation(async (cb) => {
Expand Down
Loading
Loading