Skip to content

Commit f274364

Browse files
committed
feat: enhance role management and access control features
- Introduced new internal roles and their associated permissions to improve access control granularity. - Updated the roles controller to support dynamic role filtering based on query parameters for admin and guest roles. - Enhanced the roles service with validation for role inheritance and restrictions on role naming conventions. - Improved DTOs to enforce validation rules for role creation and updates, ensuring compliance with naming standards. - Added comprehensive access control grants for internal roles, streamlining permission management across resources.
1 parent 4d0c059 commit f274364

File tree

12 files changed

+353
-34
lines changed

12 files changed

+353
-34
lines changed

apps/api/src/_common/types/ac-types.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1+
import { IAccessInfo } from "nest-access-control"
2+
13
export const AC_ADMIN_ROLE = 'admin'
24
export const AC_GUEST_ROLE = 'guest'
35

6+
export const AC_INTERNAL_ROLE_PREFIX = 'interne_'
7+
export const AC_INTERNAL_ROLE_LECTURE = 'interne_lecture'
8+
export const AC_INTERNAL_ROLE_ECRITURE = 'interne_ecriture'
9+
export const AC_INTERNAL_ROLE_GESTION = 'interne_gestion'
10+
11+
export const AC_DEFAULT_ROLES = [
12+
{ name: AC_ADMIN_ROLE, displayName: 'Administrateur', description: 'Accès total au système.', inherits: [] },
13+
{ name: AC_GUEST_ROLE, displayName: 'Invité', description: 'Accès minimal (base commune).', inherits: [] },
14+
] as const
15+
16+
export const AC_INTERNAL_DEFAULT_ROLES = [
17+
{ name: AC_INTERNAL_ROLE_LECTURE, displayName: 'Interne - Lecture', description: 'Lecture sur les ressources internes.', inherits: [AC_GUEST_ROLE] },
18+
{ name: AC_INTERNAL_ROLE_ECRITURE, displayName: 'Interne - Écriture', description: 'Création / mise à jour sur les ressources internes.', inherits: [AC_INTERNAL_ROLE_LECTURE] },
19+
{ name: AC_INTERNAL_ROLE_GESTION, displayName: 'Interne - Gestion', description: 'Gestion avancée (dont suppression) sur les ressources internes.', inherits: [AC_INTERNAL_ROLE_ECRITURE] },
20+
] as const
21+
22+
export const AC_ALL_DEFAULT_ROLES = [
23+
...AC_DEFAULT_ROLES,
24+
...AC_INTERNAL_DEFAULT_ROLES,
25+
] as const
26+
427
export enum AC_ACTIONS {
528
CREATE = 'create',
629
READ = 'read',
@@ -14,3 +37,55 @@ export enum AC_POSSESSIONS {
1437
}
1538

1639
export const AC_DEFAULT_POSSESSION = AC_POSSESSIONS.ANY
40+
41+
export const AC_INTERNAL_DEFAULT_ROLES_GRANTS: IAccessInfo[] = [
42+
// AC_INTERNAL_ROLE_LECTURE
43+
{ role: AC_INTERNAL_ROLE_LECTURE, action: AC_ACTIONS.READ, resource: '/management/identities' },
44+
45+
{ role: AC_INTERNAL_ROLE_LECTURE, action: AC_ACTIONS.READ, resource: '/management/lifecycle' },
46+
47+
// AC_INTERNAL_ROLE_ECRITURE
48+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.CREATE, resource: '/management/identities' },
49+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.UPDATE, resource: '/management/identities' },
50+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.DELETE, resource: '/management/identities' },
51+
52+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.CREATE, resource: '/management/lifecycle' },
53+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.UPDATE, resource: '/management/lifecycle' },
54+
{ role: AC_INTERNAL_ROLE_ECRITURE, action: AC_ACTIONS.DELETE, resource: '/management/lifecycle' },
55+
56+
// AC_INTERNAL_ROLE_GESTION
57+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/core/agents' },
58+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/core/agents' },
59+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/core/agents' },
60+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/core/agents' },
61+
62+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/core/audits' },
63+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/core/audits' },
64+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/core/audits' },
65+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/core/audits' },
66+
67+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/core/cron' },
68+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/core/cron' },
69+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/core/cron' },
70+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/core/cron' },
71+
72+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/core/jobs' },
73+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/core/jobs' },
74+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/core/jobs' },
75+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/core/jobs' },
76+
77+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/settings/mailadm' },
78+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/settings/mailadm' },
79+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/settings/mailadm' },
80+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/settings/mailadm' },
81+
82+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/settings/passwdadm' },
83+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/settings/passwdadm' },
84+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/settings/passwdadm' },
85+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/settings/passwdadm' },
86+
87+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.READ, resource: '/settings/smsadm' },
88+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.CREATE, resource: '/settings/smsadm' },
89+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.UPDATE, resource: '/settings/smsadm' },
90+
{ role: AC_INTERNAL_ROLE_GESTION, action: AC_ACTIONS.DELETE, resource: '/settings/smsadm' },
91+
] as const

apps/api/src/core/roles/_dto/parts/access.part.dto.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { ApiProperty } from '@nestjs/swagger'
2-
import { IsString, IsNotEmpty, IsNumber, IsDate, IsOptional, IsArray, IsEnum, IsUrl, IsDataURI } from 'class-validator'
2+
import { IsString, IsNotEmpty, IsOptional, IsArray, IsEnum, Matches } from 'class-validator'
33
import { AC_ACTIONS, AC_POSSESSIONS } from '~/_common/types/ac-types'
44

55
export class AccessPartDTO {
66
@IsString()
77
@IsNotEmpty()
88
@ApiProperty()
9-
@IsDataURI()
9+
@Matches(/^\/(?!.*\/$).+$/, {
10+
message: 'Le champ resource doit correspondre au chemin d\'accès d\'une route NestJS ex: "/core/roles" (doit commencer par / et ne pas finir par /)',
11+
})
1012
public resource: string
1113

1214
@IsArray()
1315
@IsString({ each: true })
1416
@IsNotEmpty()
1517
@IsEnum(AC_ACTIONS, { each: true })
1618
@ApiProperty({ enum: AC_ACTIONS })
17-
public actions: AC_ACTIONS[]
19+
public action: AC_ACTIONS[]
1820

1921
@IsString()
2022
@IsOptional()

apps/api/src/core/roles/_dto/roles.dto.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { ApiProperty, PartialType } from "@nestjs/swagger"
22
import { Type } from "class-transformer"
3-
import { IsArray, IsMongoId, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"
3+
import { IsArray, IsMongoId, IsNotEmpty, IsOptional, IsString, Matches, ValidateNested } from "class-validator"
44
import { CustomFieldsDto } from "~/_common/abstracts/dto/custom-fields.dto"
55
import { AccessPartDTO } from "./parts/access.part.dto"
66

7+
import { AC_INTERNAL_ROLE_PREFIX } from "~/_common/types/ac-types"
8+
79
export class RolesCreateDto extends CustomFieldsDto {
810
/**
911
* Nom du rôle.
@@ -13,6 +15,9 @@ export class RolesCreateDto extends CustomFieldsDto {
1315
*/
1416
@IsString()
1517
@IsNotEmpty()
18+
@Matches(new RegExp(`^(?!${AC_INTERNAL_ROLE_PREFIX}).+$`), {
19+
message: `Le nom ne peut pas commencer par "${AC_INTERNAL_ROLE_PREFIX}" (préfixe réservé)`,
20+
})
1621
@ApiProperty()
1722
public name: string
1823

apps/api/src/core/roles/_schemas/roles.schema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"
22
import { AbstractSchema } from "~/_common/abstracts/schemas/abstract.schema"
33
import { AccessPart, AccessPartSchema } from "./_parts/access.part.schema"
4-
import { AC_ADMIN_ROLE, AC_GUEST_ROLE } from "~/_common/types/ac-types"
4+
import { AC_ADMIN_ROLE, AC_GUEST_ROLE, AC_INTERNAL_ROLE_PREFIX } from "~/_common/types/ac-types"
55

66
@Schema({ versionKey: false })
77
export class Roles extends AbstractSchema {
@@ -16,6 +16,7 @@ export class Roles extends AbstractSchema {
1616
return v.length > 0
1717
&& !v.includes(' ')
1818
&& !!/[a-z0-9-_]+/.test(v)
19+
&& !v.startsWith(AC_INTERNAL_ROLE_PREFIX)
1920
&& ![AC_ADMIN_ROLE, AC_GUEST_ROLE].includes(v)
2021
},
2122
message: 'Le nom doit être composé de lettres, de chiffres, de tirets et de underscores et ne peut pas être un mot reservé.',
@@ -37,7 +38,7 @@ export class Roles extends AbstractSchema {
3738

3839
@Prop({
3940
type: [String],
40-
default: [],
41+
default: ['guest'],
4142
trim: true,
4243
lowercase: true,
4344
})

apps/api/src/core/roles/roles.controller.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { RolesService } from "./roles.service"
33
import { ApiParam, ApiTags } from "@nestjs/swagger"
44
import { Body, Controller, Delete, Get, HttpStatus, Param, Patch, Post, Query, Res, UseGuards } from "@nestjs/common"
55
import { ApiCreateDecorator } from "~/_common/decorators/api-create.decorator"
6-
import { RolesCreateDto, RolesDto } from "./_dto/roles.dto"
6+
import { RolesCreateDto, RolesDto, RolesUpdateDto } from "./_dto/roles.dto"
77
import { Response } from "express"
88
import { ApiPaginatedDecorator } from "~/_common/decorators/api-paginated.decorator"
99
import { PickProjectionHelper } from "~/_common/helpers/pick-projection.helper"
@@ -12,10 +12,9 @@ import { FilterOptions, FilterSchema, ObjectIdValidationPipe, SearchFilterOption
1212
import { ApiReadResponseDecorator } from "~/_common/decorators/api-read-response.decorator"
1313
import { Types } from "mongoose"
1414
import { ApiUpdateDecorator } from "~/_common/decorators/api-update.decorator"
15-
import { AgentsDto, AgentsUpdateDto } from "../agents/_dto/agents.dto"
1615
import { ApiDeletedResponseDecorator } from "~/_common/decorators/api-deleted-response.decorator"
1716
import { UseRoles } from "~/_common/decorators/use-roles.decorator"
18-
import { AC_ACTIONS, AC_ADMIN_ROLE, AC_DEFAULT_POSSESSION, AC_GUEST_ROLE } from "~/_common/types/ac-types"
17+
import { AC_ACTIONS, AC_ALL_DEFAULT_ROLES, AC_DEFAULT_POSSESSION, AC_GUEST_ROLE } from "~/_common/types/ac-types"
1918
import { Roles } from "./_schemas/roles.schema"
2019
import { AclRuntimeService } from "./acl-runtime.service"
2120

@@ -48,24 +47,30 @@ export class RolesController extends AbstractController {
4847
})
4948
public async list(
5049
@Res() res: Response,
50+
@Query('excludeAdmin') excludeAdmin: string,
51+
@Query('excludeGuest') excludeGuest: string,
5152
): Promise<Response> {
52-
const data = await this._service.find(null, { name: 1, displayName: 1 }) as unknown as Roles[]
53+
const data = await this._service.find(null, { name: 1, displayName: 1, description: 1 }) as unknown as Roles[]
54+
55+
const shouldExcludeAdmin = `${excludeAdmin ?? ''}`.toLowerCase() === 'true'
56+
const shouldExcludeGuest = `${excludeGuest ?? ''}`.toLowerCase() === 'true'
57+
const filter = (r: { name: string }) =>
58+
(!shouldExcludeAdmin || r.name !== 'admin')
59+
&& (!shouldExcludeGuest || r.name !== 'guest')
5360

5461
return res.status(HttpStatus.OK).json({
5562
statusCode: HttpStatus.OK,
5663
data: [
57-
{
58-
name: AC_ADMIN_ROLE,
59-
displayName: AC_ADMIN_ROLE.charAt(0).toUpperCase() + AC_ADMIN_ROLE.slice(1),
60-
},
61-
{
62-
name: AC_GUEST_ROLE,
63-
displayName: AC_GUEST_ROLE.charAt(0).toUpperCase() + AC_GUEST_ROLE.slice(1),
64-
},
64+
...AC_ALL_DEFAULT_ROLES.map((r) => ({
65+
name: r.name,
66+
displayName: r.displayName,
67+
description: (r as any).description,
68+
})).filter(filter),
6569
...data.map((role) => ({
6670
name: role.name,
6771
displayName: role.displayName || role.name.charAt(0).toUpperCase() + role.name.slice(1),
68-
})),
72+
description: role.description,
73+
})).filter(filter),
6974
],
7075
})
7176
}
@@ -155,7 +160,9 @@ export class RolesController extends AbstractController {
155160
@Param('_id', ObjectIdValidationPipe) _id: Types.ObjectId,
156161
@Res() res: Response,
157162
): Promise<Response> {
158-
const data = await this._service.findById(_id)
163+
const data = await this._service.findById(_id) as unknown as Roles
164+
data.inherits = data.inherits.filter((inherit) => inherit !== AC_GUEST_ROLE)
165+
159166
return res.status(HttpStatus.OK).json({
160167
statusCode: HttpStatus.OK,
161168
data,
@@ -169,14 +176,15 @@ export class RolesController extends AbstractController {
169176
possession: AC_DEFAULT_POSSESSION,
170177
})
171178
@ApiParam({ name: '_id', type: String })
172-
@ApiUpdateDecorator(AgentsUpdateDto, AgentsDto)
179+
@ApiUpdateDecorator(RolesUpdateDto, RolesDto)
173180
public async update(
174181
@Param('_id', ObjectIdValidationPipe) _id: Types.ObjectId,
175-
@Body() body: AgentsUpdateDto,
182+
@Body() body: RolesUpdateDto,
176183
@Res() res: Response,
177184
): Promise<Response> {
178185
const data = await this._service.update(_id, body)
179186
await this._aclRuntimeService.refresh()
187+
180188
return res.status(HttpStatus.OK).json({
181189
statusCode: HttpStatus.OK,
182190
data,
@@ -190,7 +198,7 @@ export class RolesController extends AbstractController {
190198
possession: AC_DEFAULT_POSSESSION,
191199
})
192200
@ApiParam({ name: '_id', type: String })
193-
@ApiDeletedResponseDecorator(AgentsDto)
201+
@ApiDeletedResponseDecorator(RolesDto)
194202
public async remove(
195203
@Param('_id', ObjectIdValidationPipe) _id: Types.ObjectId,
196204
@Res() res: Response,

0 commit comments

Comments
 (0)