Skip to content

Commit 4a71fd3

Browse files
committed
feat: enhance manual lifecycle transitions with filtering support
- Added optional filter functionality to manual transition rules in lifecycle configuration, allowing for more granular control based on identity attributes. - Updated related components and services to accommodate the new filter field, ensuring backward compatibility. - Enhanced validation to prevent duplicate source/filter combinations in manual transitions. - Improved export functionality in the identities page to support dynamic column selection for better user experience.
1 parent 7b6e29d commit 4a71fd3

19 files changed

Lines changed: 381 additions & 55 deletions

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ dev: ## Start development environment
126126
--name $(APP_NAME) \
127127
-e SESAME_SENTRY_DSN=$(SESAME_SENTRY_DSN) \
128128
-e SESAME_CRON_LOG_ROTATE_MAX_SIZE_BYTES=10485760 \
129+
-e SESAME_WEB_HMR_CLIENT_PORT=$(APP_WEB_PORT) \
129130
-p $(APP_WEB_PORT):3000 \
130131
-p $(APP_WEB_PORT_SECURE):3443 \
131132
-p $(APP_API_PORT):4000 \
@@ -148,6 +149,7 @@ debug: ## Start debug environment
148149
--network dev \
149150
--name $(APP_NAME) \
150151
-e SESAME_SENTRY_DSN=$(SESAME_SENTRY_DSN) \
152+
-e SESAME_WEB_HMR_CLIENT_PORT=$(APP_WEB_PORT) \
151153
-p $(APP_WEB_PORT):3000 \
152154
-p $(APP_WEB_PORT_SECURE):3443 \
153155
-p $(APP_API_PORT):4000 \

apps/api/defaults/lifecycle/manual-transitions.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,37 @@
11
# Transitions manuelles uniquement (changement via l'interface identité).
22
# N'affecte pas les règles automatiques (cron, fichiers rules/*.yml).
3-
# Si un état source n'est pas listé ici, toutes les destinations restent autorisées.
3+
#
4+
# Comportement permissif :
5+
# - Si un état source n'est pas listé ici, toutes les destinations restent autorisées.
6+
# - Plusieurs règles peuvent partager le même état source si leurs filtres diffèrent.
7+
# - Pour un état source donné, la première règle dont le filtre correspond à l'identité est appliquée.
8+
# - Si aucune règle filtrée ne correspond, la règle sans filtre pour cet état source est utilisée.
9+
# - Si seules des règles filtrées existent et qu'aucune ne correspond, toutes les destinations restent autorisées.
10+
#
11+
# Le champ `filter` est optionnel et utilise la syntaxe d'une requête MongoDB sur le document identité
12+
# (ex. inetOrgPerson.employeeType, opérateurs $ne, $in, etc.).
413

514
# manualTransitions:
6-
# # Officiel (O) : activation courante, pas de passage direct vers « A détruire » ou « Caché »
15+
# # Officiel (O) — employés TAIGA : pas de passage direct vers « Verrouillé »
16+
# - source: O
17+
# filter:
18+
# inetOrgPerson.employeeType: TAIGA
19+
# targets: [I, M, A, P]
20+
21+
# # Officiel (O) — défaut pour les autres employeeType
722
# - source: O
823
# targets: [I, M, A, P, V]
924

1025
# # Inactif (I) : réactivation ou mise en file de suppression
1126
# - source: I
1227
# targets: [O, M, D]
1328

29+
# # Inactif (I) — TAIGA : retour officiel uniquement
30+
# - source: I
31+
# filter:
32+
# inetOrgPerson.employeeType: TAIGA
33+
# targets: [O]
34+
1435
# # Manuel (M) : état opérateur, retours usuels
1536
# - source: M
1637
# targets: [O, I, A, P]

apps/api/src/management/identities/identities-crud.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ export class IdentitiesCrudController extends AbstractController {
362362

363363
if (body.lifecycle && body.lifecycle !== identity.lifecycle) {
364364
const manualTransitions = await loadManualTransitions();
365-
if (!isManualLifecycleTransitionAllowed(identity.lifecycle, body.lifecycle, manualTransitions)) {
365+
if (!isManualLifecycleTransitionAllowed(identity.lifecycle, body.lifecycle, manualTransitions, identity as Record<string, unknown>)) {
366366
throw new BadRequestException(
367367
`Changement manuel de cycle de vie non autorisé : <${identity.lifecycle}> → <${body.lifecycle}>`,
368368
);

apps/api/src/management/lifecycle/_dto/manual-transitions.dto.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiProperty } from '@nestjs/swagger';
22
import { Type } from 'class-transformer';
3-
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
3+
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
44

55
export class ManualTransitionRuleDto {
66
@IsString()
@@ -23,6 +23,17 @@ export class ManualTransitionRuleDto {
2323
required: true,
2424
})
2525
targets: string[];
26+
27+
@IsOptional()
28+
@IsObject()
29+
@ApiProperty({
30+
type: Object,
31+
description:
32+
"Filtre optionnel sur l'identité (syntaxe requête MongoDB). Si absent, la règle s'applique par défaut pour l'état source.",
33+
example: { 'inetOrgPerson.employeeType': 'TAIGA' },
34+
required: false,
35+
})
36+
filter?: object;
2637
}
2738

2839
export class ManualTransitionsSchemaDto {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ManualTransitionRuleDto } from '../_dto/manual-transitions.dto';
2+
import { isEmptyManualTransitionFilter, matchesIdentityFilter } from './matches-identity-filter.function';
3+
4+
export function findManualTransitionRule(
5+
source: string,
6+
rules: ManualTransitionRuleDto[],
7+
identity?: Record<string, unknown>,
8+
): ManualTransitionRuleDto | undefined {
9+
const sourceRules = rules.filter((rule) => rule.source === source);
10+
if (sourceRules.length === 0) {
11+
return undefined;
12+
}
13+
14+
if (identity) {
15+
for (const rule of sourceRules) {
16+
if (!isEmptyManualTransitionFilter(rule.filter) && matchesIdentityFilter(identity, rule.filter!)) {
17+
return rule;
18+
}
19+
}
20+
}
21+
22+
return sourceRules.find((rule) => isEmptyManualTransitionFilter(rule.filter));
23+
}
24+
25+
export function getManualTransitionRuleKey(rule: ManualTransitionRuleDto): string {
26+
const filterKey = rule.filter ? JSON.stringify(rule.filter) : '';
27+
return `${rule.source}::${filterKey}`;
28+
}

apps/api/src/management/lifecycle/_functions/is-manual-lifecycle-transition-allowed.function.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ManualTransitionRuleDto } from '../_dto/manual-transitions.dto';
2+
import { findManualTransitionRule } from './find-manual-transition-rule.function';
23

34
export function getAllowedManualLifecycleTargets(
45
source: string,
56
rules: ManualTransitionRuleDto[],
7+
identity?: Record<string, unknown>,
68
): string[] | null {
7-
const rule = rules.find((item) => item.source === source);
9+
const rule = findManualTransitionRule(source, rules, identity);
810
if (!rule) {
911
return null;
1012
}
@@ -16,12 +18,13 @@ export function isManualLifecycleTransitionAllowed(
1618
from: string,
1719
to: string,
1820
rules: ManualTransitionRuleDto[],
21+
identity?: Record<string, unknown>,
1922
): boolean {
2023
if (!from || !to || from === to) {
2124
return true;
2225
}
2326

24-
const allowedTargets = getAllowedManualLifecycleTargets(from, rules);
27+
const allowedTargets = getAllowedManualLifecycleTargets(from, rules, identity);
2528
if (allowedTargets === null) {
2629
return true;
2730
}

apps/api/src/management/lifecycle/_functions/load-manual-transitions.function.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { validateOrReject } from 'class-validator';
55
import { Logger } from '@nestjs/common';
66
import { formatValidationErrors } from '../../../_common/functions/format-validation-errors.function';
77
import { ManualTransitionRuleDto, ManualTransitionsSchemaDto } from '../_dto/manual-transitions.dto';
8+
import { getManualTransitionRuleKey } from './find-manual-transition-rule.function';
89

910
let __manualTransitionsCache: ManualTransitionRuleDto[] | null = null;
1011
let __manualTransitionsCacheMtime: number | null = null;
@@ -45,12 +46,13 @@ export async function loadManualTransitions(): Promise<ManualTransitionRuleDto[]
4546

4647
await validateOrReject(schema, { whitelist: true });
4748

48-
const usedSources = new Set<string>();
49+
const usedRuleKeys = new Set<string>();
4950
for (const rule of schema.manualTransitions) {
50-
if (usedSources.has(rule.source)) {
51-
throw new Error(`Duplicate manual transition source '${rule.source}' found in manual-transitions.yml`);
51+
const ruleKey = getManualTransitionRuleKey(rule);
52+
if (usedRuleKeys.has(ruleKey)) {
53+
throw new Error(`Duplicate manual transition rule '${ruleKey}' found in manual-transitions.yml`);
5254
}
53-
usedSources.add(rule.source);
55+
usedRuleKeys.add(ruleKey);
5456
}
5557

5658
__manualTransitionsCache = schema.manualTransitions;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import sift from 'sift';
2+
3+
export function isEmptyManualTransitionFilter(filter?: object): boolean {
4+
return !filter || Object.keys(filter).length === 0;
5+
}
6+
7+
export function matchesIdentityFilter(identity: Record<string, unknown>, filter: object): boolean {
8+
if (isEmptyManualTransitionFilter(filter)) {
9+
return true;
10+
}
11+
12+
return sift(filter)(identity);
13+
}

apps/api/src/management/lifecycle/lifecycle-config.service.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { LifecycleCrudService } from './lifecycle-crud.service';
2626
import { LifecycleHooksService } from './lifecycle-hooks.service';
2727
import { ManualTransitionRuleDto, ManualTransitionsSchemaDto, ManualTransitionsUpdateDto } from './_dto/manual-transitions.dto';
2828
import { clearManualTransitionsCache, loadManualTransitions } from './_functions/load-manual-transitions.function';
29+
import { getManualTransitionRuleKey } from './_functions/find-manual-transition-rule.function';
2930

3031
export type LifecycleRuleFileSummary = {
3132
name: string;
@@ -175,12 +176,13 @@ export class LifecycleConfigService {
175176
throw new BadRequestException(formatValidationErrors(errors, 'manual-transitions.yml'));
176177
}
177178

178-
const usedSources = new Set<string>();
179+
const usedRuleKeys = new Set<string>();
179180
for (const rule of schema.manualTransitions) {
180-
if (usedSources.has(rule.source)) {
181-
throw new BadRequestException(`La source <${rule.source}> est définie plusieurs fois.`);
181+
const ruleKey = getManualTransitionRuleKey(rule);
182+
if (usedRuleKeys.has(ruleKey)) {
183+
throw new BadRequestException(`La combinaison source/filtre <${rule.source}> est définie plusieurs fois.`);
182184
}
183-
usedSources.add(rule.source);
185+
usedRuleKeys.add(ruleKey);
184186
}
185187

186188
this.ensureLifecycleDir();

apps/api/tests/unit/management/lifecycle/is-manual-lifecycle-transition-allowed.function.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,25 @@ describe('isManualLifecycleTransitionAllowed', () => {
2323
it('should always allow staying on the current lifecycle', () => {
2424
expect(isManualLifecycleTransitionAllowed('I', 'I', rules)).toBe(true);
2525
});
26+
27+
it('should apply filtered rules before default rules for the same source', () => {
28+
const filteredRules = [
29+
{ source: 'O', filter: { 'inetOrgPerson.employeeType': 'TAIGA' }, targets: ['I', 'M'] },
30+
{ source: 'O', targets: ['I', 'M', 'A', 'P', 'V'] },
31+
];
32+
const taigaIdentity = { inetOrgPerson: { employeeType: 'TAIGA' } };
33+
const otherIdentity = { inetOrgPerson: { employeeType: 'CDI' } };
34+
35+
expect(getAllowedManualLifecycleTargets('O', filteredRules, taigaIdentity)).toEqual(['I', 'M']);
36+
expect(isManualLifecycleTransitionAllowed('O', 'V', filteredRules, taigaIdentity)).toBe(false);
37+
expect(isManualLifecycleTransitionAllowed('O', 'V', filteredRules, otherIdentity)).toBe(true);
38+
});
39+
40+
it('should remain permissive when only filtered rules exist and none match', () => {
41+
const filteredRules = [{ source: 'O', filter: { 'inetOrgPerson.employeeType': 'TAIGA' }, targets: ['I'] }];
42+
const otherIdentity = { inetOrgPerson: { employeeType: 'CDI' } };
43+
44+
expect(getAllowedManualLifecycleTargets('O', filteredRules, otherIdentity)).toBeNull();
45+
expect(isManualLifecycleTransitionAllowed('O', 'V', filteredRules, otherIdentity)).toBe(true);
46+
});
2647
});

0 commit comments

Comments
 (0)