Skip to content

Commit 73116d9

Browse files
committed
groupes dynamique
1 parent 74ca43c commit 73116d9

12 files changed

Lines changed: 601 additions & 119 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
tasks:
2+
- name: "groups-dynamic-refresh"
3+
description: "Recalcul des membres des groupes dynamiques et synchronisation des groupes modifiés"
4+
enabled: false
5+
schedule: "*/15 * * * *" # Toutes les 15 minutes
6+
handler: "groups-dynamic-refresh"
7+
options:

‎apps/api/src/core/backends/backends.command.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ModuleRef } from '@nestjs/core';
22
import { Command, CommandRunner, SubCommand } from 'nest-commander';
33
import { CronConsoleHandler } from '~/_common/decorators/cron-console-handler.decorator';
44
import { BackendsService } from '~/core/backends/backends.service';
5+
import { GroupsService } from '~/management/groups/groups.service';
56

67
@CronConsoleHandler({
78
handler: 'backends-syncall',
@@ -30,7 +31,40 @@ export class BackendsSyncallCommand extends CommandRunner {
3031
}
3132
}
3233

33-
@Command({ name: 'backends', arguments: '<task>', subCommands: [BackendsSyncallCommand] })
34+
@CronConsoleHandler({
35+
handler: 'groups-dynamic-refresh',
36+
command: 'backends refresh-dynamic-groups',
37+
label: 'Recalcul des membres des groupes dynamiques et synchronisation des groupes modifiés',
38+
})
39+
@SubCommand({ name: 'refresh-dynamic-groups' })
40+
export class BackendsRefreshDynamicGroupsCommand extends CommandRunner {
41+
public constructor(
42+
protected moduleRef: ModuleRef,
43+
private readonly backendsService: BackendsService,
44+
) {
45+
super();
46+
}
47+
48+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
49+
async run(inputs: string[], options: any): Promise<void> {
50+
// GroupsService est résolu à la demande, comme dans BackendsService (dépendance circulaire entre modules)
51+
const groupsService = this.moduleRef.get(GroupsService, { strict: false });
52+
const changed = await groupsService.refreshDynamicGroups();
53+
console.log(`${changed.length} groupe(s) dynamique(s) modifié(s)`);
54+
if (!changed.length) return;
55+
56+
const result = await this.backendsService.syncGroups(changed, { async: true });
57+
for (const group of Object.values(result || {})) {
58+
console.log(group);
59+
}
60+
}
61+
}
62+
63+
@Command({
64+
name: 'backends',
65+
arguments: '<task>',
66+
subCommands: [BackendsSyncallCommand, BackendsRefreshDynamicGroupsCommand],
67+
})
3468
export class BackendsCommand extends CommandRunner {
3569
public constructor(protected moduleRef: ModuleRef) {
3670
super();

‎apps/api/src/management/groups/_dto/groups.dto.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
IsEnum,
88
IsMongoId,
99
IsNotEmpty,
10+
IsObject,
1011
IsOptional,
1112
IsString,
1213
} from 'class-validator';
1314
import { CustomFieldsDto } from '~/_common/abstracts/dto/custom-fields.dto';
1415
import { IdentityState } from '~/management/identities/_enums/states.enum';
16+
import { GroupType } from '../_schemas/groups.schema';
1517

1618
export class GroupsCreateDto extends CustomFieldsDto {
1719
@IsString()
@@ -34,10 +36,34 @@ export class GroupsCreateDto extends CustomFieldsDto {
3436
@ApiProperty({ type: String, required: false, nullable: true, description: 'Identifiant de la famille du groupe' })
3537
public family?: string | null;
3638

39+
@IsEnum(GroupType)
40+
@IsOptional()
41+
@ApiProperty({
42+
enum: GroupType,
43+
required: false,
44+
default: GroupType.STATIC,
45+
description: 'Groupe normal ou dynamique',
46+
})
47+
public type?: GroupType;
48+
49+
@IsObject()
50+
@IsOptional()
51+
@ApiProperty({
52+
type: Object,
53+
required: false,
54+
nullable: true,
55+
description: 'Filtre de sélection des membres d’un groupe dynamique (clés signées, ex: { "@state": ["1"] })',
56+
})
57+
public filters?: Record<string, unknown> | null;
58+
3759
@IsArray()
3860
@IsMongoId({ each: true })
3961
@IsOptional()
40-
@ApiProperty({ type: [String], required: false, description: 'Identifiants des identités membres' })
62+
@ApiProperty({
63+
type: [String],
64+
required: false,
65+
description: 'Identifiants des identités membres (ignoré pour un groupe dynamique)',
66+
})
4167
public member?: string[];
4268

4369
@IsArray()
@@ -72,6 +98,12 @@ export class GroupsMembersDto {
7298
public members: string[];
7399
}
74100

101+
export class GroupsFiltersPreviewDto {
102+
@IsObject()
103+
@ApiProperty({ type: Object, description: 'Filtre de groupe dynamique à évaluer' })
104+
public filters: Record<string, unknown>;
105+
}
106+
75107
export class GroupsMemberOfDto {
76108
@IsArray()
77109
@IsMongoId({ each: true })

‎apps/api/src/management/groups/_schemas/groups.schema.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import { IdentityState } from '~/management/identities/_enums/states.enum';
77

88
export type GroupsDocument = Groups & Document;
99

10+
export enum GroupType {
11+
STATIC = 'static',
12+
DYNAMIC = 'dynamic',
13+
}
14+
1015
/**
11-
* Groupe statique d'identités, équivalent de l'objectClass LDAP `groupOfNames`.
16+
* Groupe d'identités, équivalent de l'objectClass LDAP `groupOfNames`.
1217
* L'appartenance est portée par le groupe (`member`), le `memberOf` d'une identité est calculé.
18+
* Pour un groupe dynamique, `member` est calculé à partir de `filters`.
1319
*/
1420
@Schema({ versionKey: false, minimize: false })
1521
export class Groups extends AbstractSchema {
@@ -25,6 +31,17 @@ export class Groups extends AbstractSchema {
2531
@Prop({ type: Types.ObjectId, ref: 'GroupFamilies', default: null })
2632
public family?: Types.ObjectId | null;
2733

34+
@Prop({ type: String, enum: GroupType, default: GroupType.STATIC })
35+
public type: GroupType;
36+
37+
/**
38+
* Filtre de sélection des membres d'un groupe dynamique, au format des clés signées
39+
* de `filters[...]` (ex: `{ "@state": ["1"], "^inetOrgPerson.employeeType": "/^etd/i" }`).
40+
* Le résultat de son évaluation est stocké dans `member`.
41+
*/
42+
@Prop({ type: Object, default: null })
43+
public filters?: Record<string, unknown> | null;
44+
2845
@Prop({ type: [Types.ObjectId], ref: 'Identities', default: [] })
2946
public member: Types.ObjectId[];
3047

@@ -46,4 +63,5 @@ export const GroupsSchema = SchemaFactory.createForClass(Groups)
4663
collectionName: Groups.name,
4764
})
4865
.index({ member: 1 })
49-
.index({ family: 1 });
66+
.index({ family: 1 })
67+
.index({ type: 1 });

‎apps/api/src/management/groups/groups.controller.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
GroupsCreateDto,
2525
GroupsDto,
2626
GroupsIdsDto,
27+
GroupsFiltersPreviewDto,
2728
GroupsMemberOfDto,
2829
GroupsMembersDto,
2930
GroupsSyncDto,
@@ -40,6 +41,7 @@ export class GroupsController extends AbstractController {
4041
description: 1,
4142
mail: 1,
4243
family: 1,
44+
type: 1,
4345
member: 1,
4446
owner: 1,
4547
state: 1,
@@ -124,6 +126,21 @@ export class GroupsController extends AbstractController {
124126
});
125127
}
126128

129+
@Post('preview-members')
130+
@UseRoles({
131+
resource: '/management/groups',
132+
action: AC_ACTIONS.READ,
133+
possession: AC_DEFAULT_POSSESSION,
134+
})
135+
@ApiOperation({ summary: "Nombre d'identités sélectionnées par le filtre d'un groupe dynamique" })
136+
public async previewMembers(@Res() res: Response, @Body() body: GroupsFiltersPreviewDto): Promise<Response> {
137+
const total = await this._service.countDynamicMembers(body.filters);
138+
return res.status(HttpStatus.OK).json({
139+
statusCode: HttpStatus.OK,
140+
data: { total },
141+
});
142+
}
143+
127144
@Post('sync')
128145
@UseRoles({
129146
resource: '/management/groups',

‎apps/api/src/management/groups/groups.service.ts‎

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import {
1313
SaveOptions,
1414
Types,
1515
} from 'mongoose';
16+
import { filterSchema, SearchFilterInput } from '@tacxou/nestjs_module_restools/search-filter-schema';
1617
import { AbstractServiceSchema } from '~/_common/abstracts/abstract.service.schema';
1718
import { AbstractSchema } from '~/_common/abstracts/schemas/abstract.schema';
1819
import { normalizeMongoFilterValues } from '~/_common/functions/normalize-mongo-filter-values';
1920
import { IdentityState } from '~/management/identities/_enums/states.enum';
2021
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service';
2122
import { GroupsCreateDto, GroupsUpdateDto } from './_dto/groups.dto';
2223
import { GroupFamilies } from './_schemas/group-families.schema';
23-
import { Groups } from './_schemas/groups.schema';
24+
import { Groups, GroupType } from './_schemas/groups.schema';
2425

2526
/**
2627
* Service de gestion des groupes d'identités (type LDAP `groupOfNames`).
@@ -52,18 +53,37 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
5253
): Promise<Document<T, any, T>> {
5354
await this.ensureCnAvailable(data.cn);
5455
await this.ensureMailAvailable(data.mail);
55-
const member = await this.normalizeMembers(data.member);
5656
const owner = await this.normalizeMembers(data.owner);
5757
const family = await this.normalizeFamily(data.family);
58+
const membership =
59+
data.type === GroupType.DYNAMIC
60+
? await this.buildDynamicMembership(data.filters)
61+
: { type: GroupType.STATIC, filters: null, member: await this.normalizeMembers(data.member) };
5862

59-
return await super.create<T>({ ...data, family, member, owner, state: IdentityState.TO_SYNC }, options);
63+
return await super.create<T>({ ...data, family, owner, ...membership, state: IdentityState.TO_SYNC }, options);
6064
}
6165

6266
public async updateGroup(_id: Types.ObjectId, data: GroupsUpdateDto): Promise<ModifyResult<Query<Groups, Groups>>> {
6367
const $set: Record<string, unknown> = { ...data, state: IdentityState.TO_SYNC };
6468
if (data.cn !== undefined) await this.ensureCnAvailable(data.cn, _id);
6569
if (data.mail !== undefined) await this.ensureMailAvailable(data.mail, _id);
66-
if (data.member !== undefined) $set.member = await this.normalizeMembers(data.member);
70+
if (data.type !== undefined || data.filters !== undefined || data.member !== undefined) {
71+
const current = await this.findById<Groups>(_id, { type: 1, filters: 1 });
72+
const type = data.type ?? current.type ?? GroupType.STATIC;
73+
if (type === GroupType.DYNAMIC) {
74+
// les membres d'un groupe dynamique sont toujours recalculés à partir du filtre
75+
Object.assign(
76+
$set,
77+
await this.buildDynamicMembership(data.filters !== undefined ? data.filters : current.filters),
78+
);
79+
} else {
80+
// un groupe redevenu normal conserve ses derniers membres calculés
81+
$set.type = GroupType.STATIC;
82+
$set.filters = null;
83+
if (data.member !== undefined) $set.member = await this.normalizeMembers(data.member);
84+
else delete $set.member;
85+
}
86+
}
6787
if (data.owner !== undefined) $set.owner = await this.normalizeMembers(data.owner);
6888
if (data.family !== undefined) $set.family = await this.normalizeFamily(data.family);
6989

@@ -109,6 +129,7 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
109129
}
110130

111131
public async addMembers(_id: Types.ObjectId, ids: string[]): Promise<ModifyResult<Query<Groups, Groups>>> {
132+
await this.ensureStaticGroup(_id);
112133
const members = await this.normalizeMembers(ids);
113134
return await this.update<Groups>(_id, {
114135
$addToSet: { member: { $each: members } },
@@ -117,6 +138,7 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
117138
}
118139

119140
public async removeMembers(_id: Types.ObjectId, ids: string[]): Promise<ModifyResult<Query<Groups, Groups>>> {
141+
await this.ensureStaticGroup(_id);
120142
return await this.update<Groups>(_id, {
121143
$pull: { member: { $in: ids.map((id) => new Types.ObjectId(id)) } },
122144
$set: { state: IdentityState.TO_SYNC },
@@ -145,20 +167,28 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
145167
*/
146168
public async findMemberOf(identityId: Types.ObjectId): Promise<Groups[]> {
147169
return await this._model
148-
.find({ member: identityId }, { cn: 1, description: 1, state: 1 })
170+
.find({ member: identityId }, { cn: 1, description: 1, state: 1, type: 1 })
149171
.sort({ cn: 1 })
150172
.lean<Groups[]>()
151173
.exec();
152174
}
153175

154176
/**
155-
* Remplace la liste des groupes d'une identité en ne modifiant que les groupes concernés par la différence
177+
* Remplace la liste des groupes d'une identité en ne modifiant que les groupes concernés par la différence.
178+
* Les groupes dynamiques sont ignorés : leur appartenance ne dépend que de leur filtre.
156179
*/
157180
public async setIdentityGroups(identityId: Types.ObjectId, groupIds: string[]): Promise<Groups[]> {
158181
await this.normalizeMembers([identityId.toHexString()]);
159182

160-
const target = new Set(groupIds);
161-
const current = new Set((await this.findMemberOf(identityId)).map((group) => group._id.toHexString()));
183+
const dynamicIds = new Set(
184+
(await this._model.find({ type: GroupType.DYNAMIC }, { _id: 1 }).lean().exec()).map((group) =>
185+
group._id.toHexString(),
186+
),
187+
);
188+
const target = new Set(groupIds.filter((id) => !dynamicIds.has(id)));
189+
const current = new Set(
190+
(await this.findMemberOf(identityId)).map((group) => group._id.toHexString()).filter((id) => !dynamicIds.has(id)),
191+
);
162192
const toAdd = [...target].filter((id) => !current.has(id));
163193
const toRemove = [...current].filter((id) => !target.has(id));
164194

@@ -183,6 +213,37 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
183213
return await this.findMemberOf(identityId);
184214
}
185215

216+
/**
217+
* Réévalue le filtre de chaque groupe dynamique et met à jour ses membres s'ils ont changé.
218+
* Les groupes modifiés repassent à TO_SYNC ; leurs identifiants sont retournés pour synchronisation.
219+
*/
220+
public async refreshDynamicGroups(): Promise<string[]> {
221+
const groups = await this._model
222+
.find({ type: GroupType.DYNAMIC, deletedFlag: { $ne: true } }, { cn: 1, filters: 1, member: 1 })
223+
.lean<Groups[]>()
224+
.exec();
225+
const changed: string[] = [];
226+
227+
for (const group of groups) {
228+
let member: Types.ObjectId[];
229+
try {
230+
member = await this.resolveDynamicMembers(group.filters);
231+
} catch (error) {
232+
this.logger.error(`Filtre invalide pour le groupe dynamique <${group.cn}>: ${error?.message || error}`);
233+
continue;
234+
}
235+
236+
const before = (group.member || []).map((id) => id.toString()).sort();
237+
const after = member.map((id) => id.toString()).sort();
238+
if (before.length === after.length && before.every((id, index) => id === after[index])) continue;
239+
240+
await this.update<Groups>(group._id, { $set: { member, state: IdentityState.TO_SYNC } });
241+
changed.push(group._id.toString());
242+
}
243+
244+
return changed;
245+
}
246+
186247
/**
187248
* Passe les groupes donnés à l'état TO_SYNC (action « Mettre à synchroniser » de la liste)
188249
*/
@@ -246,6 +307,53 @@ export class GroupsService extends AbstractServiceSchema<Groups> {
246307
}
247308
}
248309

310+
protected async ensureStaticGroup(_id: Types.ObjectId): Promise<void> {
311+
const group = await this.findById<Groups>(_id, { type: 1 });
312+
if (group?.type === GroupType.DYNAMIC) {
313+
throw new BadRequestException('Les membres d’un groupe dynamique sont calculés à partir de son filtre');
314+
}
315+
}
316+
317+
protected async buildDynamicMembership(
318+
filters?: Record<string, unknown> | null,
319+
): Promise<{ type: GroupType; filters: Record<string, unknown>; member: Types.ObjectId[] }> {
320+
if (!filters || !Object.keys(filters).length) {
321+
throw new BadRequestException('Un groupe dynamique doit avoir au moins un filtre');
322+
}
323+
return { type: GroupType.DYNAMIC, filters, member: await this.resolveDynamicMembers(filters) };
324+
}
325+
326+
public async countDynamicMembers(filters?: Record<string, unknown> | null): Promise<number> {
327+
return await this.identities.model.countDocuments(this.buildDynamicFilter(filters)).exec();
328+
}
329+
330+
/**
331+
* Évalue un filtre de groupe dynamique (même format et même périmètre que /identities/count-all)
332+
* et retourne les identifiants des identités sélectionnées, triés
333+
*/
334+
protected async resolveDynamicMembers(filters?: Record<string, unknown> | null): Promise<Types.ObjectId[]> {
335+
const found = await this.identities.model
336+
.find(this.buildDynamicFilter(filters), { _id: 1 })
337+
.sort({ _id: 1 })
338+
.lean()
339+
.exec();
340+
341+
return found.map((identity) => new Types.ObjectId(identity._id.toString()));
342+
}
343+
344+
/**
345+
* Convertit le filtre stocké (clés signées) en filtre Mongo sur les identités non supprimées
346+
*/
347+
protected buildDynamicFilter(filters?: Record<string, unknown> | null): FilterQuery<unknown> {
348+
let filter: FilterQuery<unknown>;
349+
try {
350+
filter = filterSchema((filters || {}) as SearchFilterInput);
351+
} catch (error) {
352+
throw new BadRequestException(error?.message ?? 'Filtre de groupe dynamique invalide');
353+
}
354+
return normalizeMongoFilterValues({ ...filter, deletedFlag: { $ne: true } });
355+
}
356+
249357
protected async ensureCnAvailable(cn: string, excludeId?: Types.ObjectId): Promise<void> {
250358
const filter: FilterQuery<Groups> = { cn: cn?.trim() };
251359
if (excludeId) filter._id = { $ne: excludeId };

0 commit comments

Comments
 (0)