@ftisindia/create-app 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/README.md +1 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +3 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +58 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +11 -11
- package/template/src/modules/access-control/access-control.module.ts +9 -9
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
|
@@ -3,16 +3,23 @@ import {
|
|
|
3
3
|
ConflictException,
|
|
4
4
|
Injectable,
|
|
5
5
|
NotFoundException,
|
|
6
|
-
} from
|
|
7
|
-
import { Prisma } from
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Prisma } from '@prisma/client';
|
|
8
|
+
import {
|
|
9
|
+
PaginationQueryDto,
|
|
10
|
+
resolvePageLimit,
|
|
11
|
+
toPage,
|
|
12
|
+
} from '../../../../common/dto/pagination-query.dto';
|
|
13
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
14
|
+
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
15
|
+
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
16
|
+
import { CreateRoleDto } from '../../dto/create-role.dto';
|
|
17
|
+
import { UpdateRolePermissionsDto } from '../../dto/update-role-permissions.dto';
|
|
18
|
+
import { UpdateRoleDto } from '../../dto/update-role.dto';
|
|
19
|
+
import { permissionKeys } from '../../types/permission-key';
|
|
20
|
+
import { routePermissionRegistry } from '../../types/route-permission-registry';
|
|
21
|
+
import { assertPermissionChangeWithinActor } from '../role-permission-policy';
|
|
22
|
+
import { RbacCacheService } from './rbac-cache.service';
|
|
16
23
|
|
|
17
24
|
const roleInclude = {
|
|
18
25
|
permissions: {
|
|
@@ -20,7 +27,7 @@ const roleInclude = {
|
|
|
20
27
|
permission: true,
|
|
21
28
|
},
|
|
22
29
|
orderBy: {
|
|
23
|
-
permissionId:
|
|
30
|
+
permissionId: 'asc',
|
|
24
31
|
},
|
|
25
32
|
},
|
|
26
33
|
_count: {
|
|
@@ -42,7 +49,8 @@ export class AccessControlService {
|
|
|
42
49
|
|
|
43
50
|
listPermissions() {
|
|
44
51
|
return this.prisma.permission.findMany({
|
|
45
|
-
orderBy: [{ module:
|
|
52
|
+
orderBy: [{ module: 'asc' }, { action: 'asc' }],
|
|
53
|
+
take: permissionKeys.length,
|
|
46
54
|
});
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -50,23 +58,27 @@ export class AccessControlService {
|
|
|
50
58
|
return routePermissionRegistry;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
async listRoles(orgId: string) {
|
|
61
|
+
async listRoles(orgId: string, query: PaginationQueryDto) {
|
|
54
62
|
this.requestContext.assertOrgScope(orgId);
|
|
63
|
+
const limit = resolvePageLimit(query.limit);
|
|
55
64
|
|
|
56
65
|
const roles = await this.prisma.role.findMany({
|
|
57
66
|
where: { orgId },
|
|
58
67
|
include: roleInclude,
|
|
59
|
-
orderBy: [{ isSystemSeeded:
|
|
68
|
+
orderBy: [{ isSystemSeeded: 'desc' }, { name: 'asc' }],
|
|
69
|
+
take: limit + 1,
|
|
70
|
+
...(query.cursor
|
|
71
|
+
? {
|
|
72
|
+
cursor: { id: query.cursor },
|
|
73
|
+
skip: 1,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
60
76
|
});
|
|
61
77
|
|
|
62
|
-
return roles.map(serializeRole);
|
|
78
|
+
return toPage(roles.map(serializeRole), limit);
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
async createRole(
|
|
66
|
-
currentUser: AuthenticatedUser,
|
|
67
|
-
orgId: string,
|
|
68
|
-
dto: CreateRoleDto,
|
|
69
|
-
) {
|
|
81
|
+
async createRole(currentUser: AuthenticatedUser, orgId: string, dto: CreateRoleDto) {
|
|
70
82
|
this.requestContext.assertOrgScope(orgId);
|
|
71
83
|
|
|
72
84
|
const name = normalizeRoleName(dto.name);
|
|
@@ -86,7 +98,7 @@ export class AccessControlService {
|
|
|
86
98
|
await this.writeAudit(tx, {
|
|
87
99
|
orgId,
|
|
88
100
|
actorUserId: currentUser.id,
|
|
89
|
-
action:
|
|
101
|
+
action: 'role.create',
|
|
90
102
|
targetId: created.id,
|
|
91
103
|
metadata: {
|
|
92
104
|
name: created.name,
|
|
@@ -100,9 +112,7 @@ export class AccessControlService {
|
|
|
100
112
|
return serializeRole(role);
|
|
101
113
|
} catch (error) {
|
|
102
114
|
if (isPrismaUniqueError(error)) {
|
|
103
|
-
throw new ConflictException(
|
|
104
|
-
"Role name is already in use in this organisation.",
|
|
105
|
-
);
|
|
115
|
+
throw new ConflictException('Role name is already in use in this organisation.');
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
throw error;
|
|
@@ -119,25 +129,19 @@ export class AccessControlService {
|
|
|
119
129
|
|
|
120
130
|
const existing = await this.findRoleInOrg(this.prisma, orgId, roleId);
|
|
121
131
|
if (dto.name === undefined && dto.description === undefined) {
|
|
122
|
-
throw new BadRequestException(
|
|
123
|
-
"At least one role field must be provided.",
|
|
124
|
-
);
|
|
132
|
+
throw new BadRequestException('At least one role field must be provided.');
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
try {
|
|
128
136
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
129
|
-
const nextName =
|
|
130
|
-
dto.name === undefined ? existing.name : normalizeRoleName(dto.name);
|
|
137
|
+
const nextName = dto.name === undefined ? existing.name : normalizeRoleName(dto.name);
|
|
131
138
|
assertValidRoleName(nextName);
|
|
132
139
|
const nextDescription =
|
|
133
140
|
dto.description === undefined
|
|
134
141
|
? existing.description
|
|
135
142
|
: normalizeOptionalString(dto.description);
|
|
136
143
|
|
|
137
|
-
if (
|
|
138
|
-
nextName === existing.name &&
|
|
139
|
-
nextDescription === existing.description
|
|
140
|
-
) {
|
|
144
|
+
if (nextName === existing.name && nextDescription === existing.description) {
|
|
141
145
|
const role = await tx.role.findUniqueOrThrow({
|
|
142
146
|
where: { id: existing.id },
|
|
143
147
|
include: roleInclude,
|
|
@@ -158,7 +162,7 @@ export class AccessControlService {
|
|
|
158
162
|
await this.writeAudit(tx, {
|
|
159
163
|
orgId,
|
|
160
164
|
actorUserId: currentUser.id,
|
|
161
|
-
action:
|
|
165
|
+
action: 'role.update',
|
|
162
166
|
targetId: updated.id,
|
|
163
167
|
metadata: {
|
|
164
168
|
previousName: existing.name,
|
|
@@ -178,25 +182,19 @@ export class AccessControlService {
|
|
|
178
182
|
return serializeRole(result.role);
|
|
179
183
|
} catch (error) {
|
|
180
184
|
if (isPrismaUniqueError(error)) {
|
|
181
|
-
throw new ConflictException(
|
|
182
|
-
"Role name is already in use in this organisation.",
|
|
183
|
-
);
|
|
185
|
+
throw new ConflictException('Role name is already in use in this organisation.');
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
throw error;
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
async deleteRole(
|
|
191
|
-
currentUser: AuthenticatedUser,
|
|
192
|
-
orgId: string,
|
|
193
|
-
roleId: string,
|
|
194
|
-
) {
|
|
192
|
+
async deleteRole(currentUser: AuthenticatedUser, orgId: string, roleId: string) {
|
|
195
193
|
this.requestContext.assertOrgScope(orgId);
|
|
196
194
|
|
|
197
195
|
const existing = await this.findRoleInOrg(this.prisma, orgId, roleId);
|
|
198
196
|
if (existing.isSystemSeeded) {
|
|
199
|
-
throw new ConflictException(
|
|
197
|
+
throw new ConflictException('System-seeded roles cannot be deleted.');
|
|
200
198
|
}
|
|
201
199
|
|
|
202
200
|
try {
|
|
@@ -208,9 +206,7 @@ export class AccessControlService {
|
|
|
208
206
|
},
|
|
209
207
|
});
|
|
210
208
|
if (membershipCount > 0) {
|
|
211
|
-
throw new ConflictException(
|
|
212
|
-
"Role cannot be deleted while memberships still use it.",
|
|
213
|
-
);
|
|
209
|
+
throw new ConflictException('Role cannot be deleted while memberships still use it.');
|
|
214
210
|
}
|
|
215
211
|
|
|
216
212
|
const invitationCount = await tx.invitation.count({
|
|
@@ -220,9 +216,7 @@ export class AccessControlService {
|
|
|
220
216
|
},
|
|
221
217
|
});
|
|
222
218
|
if (invitationCount > 0) {
|
|
223
|
-
throw new ConflictException(
|
|
224
|
-
"Role cannot be deleted while invitations reference it.",
|
|
225
|
-
);
|
|
219
|
+
throw new ConflictException('Role cannot be deleted while invitations reference it.');
|
|
226
220
|
}
|
|
227
221
|
|
|
228
222
|
await tx.rolePermission.deleteMany({
|
|
@@ -236,7 +230,7 @@ export class AccessControlService {
|
|
|
236
230
|
await this.writeAudit(tx, {
|
|
237
231
|
orgId,
|
|
238
232
|
actorUserId: currentUser.id,
|
|
239
|
-
action:
|
|
233
|
+
action: 'role.delete',
|
|
240
234
|
targetId: roleId,
|
|
241
235
|
metadata: {
|
|
242
236
|
name: existing.name,
|
|
@@ -245,9 +239,7 @@ export class AccessControlService {
|
|
|
245
239
|
});
|
|
246
240
|
} catch (error) {
|
|
247
241
|
if (isPrismaForeignKeyError(error)) {
|
|
248
|
-
throw new ConflictException(
|
|
249
|
-
"Role cannot be deleted while other records reference it.",
|
|
250
|
-
);
|
|
242
|
+
throw new ConflictException('Role cannot be deleted while other records reference it.');
|
|
251
243
|
}
|
|
252
244
|
|
|
253
245
|
throw error;
|
|
@@ -271,7 +263,7 @@ export class AccessControlService {
|
|
|
271
263
|
});
|
|
272
264
|
|
|
273
265
|
if (!role) {
|
|
274
|
-
throw new NotFoundException(
|
|
266
|
+
throw new NotFoundException('Role was not found in this organisation.');
|
|
275
267
|
}
|
|
276
268
|
|
|
277
269
|
return serializeRole(role);
|
|
@@ -301,14 +293,10 @@ export class AccessControlService {
|
|
|
301
293
|
key: true,
|
|
302
294
|
},
|
|
303
295
|
});
|
|
304
|
-
const foundKeys = new Set(
|
|
305
|
-
permissions.map((permission) => permission.key),
|
|
306
|
-
);
|
|
296
|
+
const foundKeys = new Set(permissions.map((permission) => permission.key));
|
|
307
297
|
const missingKeys = permissionKeys.filter((key) => !foundKeys.has(key));
|
|
308
298
|
if (missingKeys.length > 0) {
|
|
309
|
-
throw new NotFoundException(
|
|
310
|
-
`Unknown permission keys: ${missingKeys.join(", ")}`,
|
|
311
|
-
);
|
|
299
|
+
throw new NotFoundException(`Unknown permission keys: ${missingKeys.join(', ')}`);
|
|
312
300
|
}
|
|
313
301
|
|
|
314
302
|
const previousPermissions = await tx.rolePermission.findMany({
|
|
@@ -328,6 +316,19 @@ export class AccessControlService {
|
|
|
328
316
|
return { role: unchanged, changed: false };
|
|
329
317
|
}
|
|
330
318
|
|
|
319
|
+
// Prevent privilege escalation: a non-owner actor may only add or
|
|
320
|
+
// remove permission keys they themselves hold (owners bypass). Without
|
|
321
|
+
// this, anyone with permissions.assign could rewrite a role to include
|
|
322
|
+
// permissions they lack and then assign it — bypassing the no-escalation
|
|
323
|
+
// checks on assignRole / createInvitation.
|
|
324
|
+
const actorContext = await this.rbacCache.getContext(currentUser.id, orgId);
|
|
325
|
+
assertPermissionChangeWithinActor({
|
|
326
|
+
actorIsOwner: actorContext.isOwner,
|
|
327
|
+
actorPermissionKeys: actorContext.permissionKeys,
|
|
328
|
+
previousPermissionKeys,
|
|
329
|
+
nextPermissionKeys: permissionKeys,
|
|
330
|
+
});
|
|
331
|
+
|
|
331
332
|
await tx.rolePermission.deleteMany({
|
|
332
333
|
where: { roleId },
|
|
333
334
|
});
|
|
@@ -345,7 +346,7 @@ export class AccessControlService {
|
|
|
345
346
|
await this.writeAudit(tx, {
|
|
346
347
|
orgId,
|
|
347
348
|
actorUserId: currentUser.id,
|
|
348
|
-
action:
|
|
349
|
+
action: 'role.permissions.replace',
|
|
349
350
|
targetId: roleId,
|
|
350
351
|
metadata: {
|
|
351
352
|
roleName: role.name,
|
|
@@ -370,7 +371,7 @@ export class AccessControlService {
|
|
|
370
371
|
} catch (error) {
|
|
371
372
|
if (isPrismaForeignKeyError(error)) {
|
|
372
373
|
throw new ConflictException(
|
|
373
|
-
|
|
374
|
+
'Role permissions could not be updated because referenced data changed.',
|
|
374
375
|
);
|
|
375
376
|
}
|
|
376
377
|
|
|
@@ -378,9 +379,12 @@ export class AccessControlService {
|
|
|
378
379
|
}
|
|
379
380
|
}
|
|
380
381
|
|
|
381
|
-
private async findRoleInOrg<
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
private async findRoleInOrg<TInclude extends Prisma.RoleInclude | undefined = undefined>(
|
|
383
|
+
client: PrismaClient,
|
|
384
|
+
orgId: string,
|
|
385
|
+
roleId: string,
|
|
386
|
+
include?: TInclude,
|
|
387
|
+
) {
|
|
384
388
|
const role = await client.role.findUnique({
|
|
385
389
|
where: {
|
|
386
390
|
id_orgId: {
|
|
@@ -392,7 +396,7 @@ export class AccessControlService {
|
|
|
392
396
|
});
|
|
393
397
|
|
|
394
398
|
if (!role) {
|
|
395
|
-
throw new NotFoundException(
|
|
399
|
+
throw new NotFoundException('Role was not found in this organisation.');
|
|
396
400
|
}
|
|
397
401
|
|
|
398
402
|
return role;
|
|
@@ -413,19 +417,18 @@ export class AccessControlService {
|
|
|
413
417
|
orgId: data.orgId,
|
|
414
418
|
actorUserId: data.actorUserId,
|
|
415
419
|
action: data.action,
|
|
416
|
-
targetType:
|
|
420
|
+
targetType: 'Role',
|
|
417
421
|
targetId: data.targetId,
|
|
418
422
|
metadata: data.metadata,
|
|
423
|
+
ipAddress: this.requestContext.getIpAddress(),
|
|
424
|
+
userAgent: this.requestContext.getUserAgent(),
|
|
419
425
|
},
|
|
420
426
|
});
|
|
421
427
|
}
|
|
422
428
|
}
|
|
423
429
|
|
|
424
430
|
function arraysEqual(left: string[], right: string[]) {
|
|
425
|
-
return (
|
|
426
|
-
left.length === right.length &&
|
|
427
|
-
left.every((value, index) => value === right[index])
|
|
428
|
-
);
|
|
431
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
429
432
|
}
|
|
430
433
|
|
|
431
434
|
function normalizeRoleName(name: string) {
|
|
@@ -434,9 +437,7 @@ function normalizeRoleName(name: string) {
|
|
|
434
437
|
|
|
435
438
|
function assertValidRoleName(name: string) {
|
|
436
439
|
if (name.length < 2) {
|
|
437
|
-
throw new BadRequestException(
|
|
438
|
-
"Role name must contain at least 2 non-whitespace characters.",
|
|
439
|
-
);
|
|
440
|
+
throw new BadRequestException('Role name must contain at least 2 non-whitespace characters.');
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
|
@@ -446,22 +447,14 @@ function normalizeOptionalString(value: string | undefined) {
|
|
|
446
447
|
}
|
|
447
448
|
|
|
448
449
|
function isPrismaUniqueError(error: unknown) {
|
|
449
|
-
return
|
|
450
|
-
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
451
|
-
error.code === "P2002"
|
|
452
|
-
);
|
|
450
|
+
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
|
453
451
|
}
|
|
454
452
|
|
|
455
453
|
function isPrismaForeignKeyError(error: unknown) {
|
|
456
|
-
return
|
|
457
|
-
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
458
|
-
error.code === "P2003"
|
|
459
|
-
);
|
|
454
|
+
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003';
|
|
460
455
|
}
|
|
461
456
|
|
|
462
|
-
function serializeRole(
|
|
463
|
-
role: Prisma.RoleGetPayload<{ include: typeof roleInclude }>,
|
|
464
|
-
) {
|
|
457
|
+
function serializeRole(role: Prisma.RoleGetPayload<{ include: typeof roleInclude }>) {
|
|
465
458
|
return {
|
|
466
459
|
id: role.id,
|
|
467
460
|
orgId: role.orgId,
|
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { REQUIRED_PERMISSIONS_KEY } from "../../presentation/permissions.decorator";
|
|
11
|
-
import { IS_PUBLIC_KEY } from "../../presentation/public.decorator";
|
|
12
|
-
import { PermissionKey, permissionKeyToRule } from "../../types/permission-key";
|
|
13
|
-
import { AbilityFactory } from "./ability.factory";
|
|
14
|
-
import { RbacCacheService } from "./rbac-cache.service";
|
|
1
|
+
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
4
|
+
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
5
|
+
import { REQUIRED_PERMISSIONS_KEY } from '../../presentation/permissions.decorator';
|
|
6
|
+
import { IS_PUBLIC_KEY } from '../../presentation/public.decorator';
|
|
7
|
+
import { PermissionKey, permissionKeyToRule } from '../../types/permission-key';
|
|
8
|
+
import { AbilityFactory } from './ability.factory';
|
|
9
|
+
import { RbacCacheService } from './rbac-cache.service';
|
|
15
10
|
|
|
16
11
|
type RequestWithUser = {
|
|
17
12
|
orgId?: string;
|
|
@@ -38,12 +33,12 @@ export class PermissionGuard implements CanActivate {
|
|
|
38
33
|
return true;
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
const required = this.reflector.getAllAndOverride<PermissionKey[]>(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
);
|
|
36
|
+
const required = this.reflector.getAllAndOverride<PermissionKey[]>(REQUIRED_PERMISSIONS_KEY, [
|
|
37
|
+
context.getHandler(),
|
|
38
|
+
context.getClass(),
|
|
39
|
+
]);
|
|
45
40
|
if (!required || required.length === 0) {
|
|
46
|
-
throw new ForbiddenException(
|
|
41
|
+
throw new ForbiddenException('Permission metadata is required.');
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
|
@@ -51,7 +46,7 @@ export class PermissionGuard implements CanActivate {
|
|
|
51
46
|
const orgId = request.orgId ?? request.params?.orgId;
|
|
52
47
|
|
|
53
48
|
if (!user || !orgId) {
|
|
54
|
-
throw new ForbiddenException(
|
|
49
|
+
throw new ForbiddenException('Active organisation context is required.');
|
|
55
50
|
}
|
|
56
51
|
|
|
57
52
|
const rbac = await this.rbacCache.getContext(user.id, orgId);
|
|
@@ -63,7 +58,7 @@ export class PermissionGuard implements CanActivate {
|
|
|
63
58
|
});
|
|
64
59
|
|
|
65
60
|
if (!allowed) {
|
|
66
|
-
throw new ForbiddenException(
|
|
61
|
+
throw new ForbiddenException('Required permission is missing.');
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
request.rbac = rbac;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ForbiddenException, Injectable } from
|
|
2
|
-
import { ConfigService } from
|
|
3
|
-
import { MembershipStatus } from
|
|
4
|
-
import { PrismaService } from
|
|
5
|
-
import { RbacContext } from
|
|
1
|
+
import { ForbiddenException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { MembershipStatus } from '@prisma/client';
|
|
4
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
5
|
+
import { RbacContext } from '../../types/rbac-context';
|
|
6
6
|
|
|
7
7
|
type CacheEntry = {
|
|
8
8
|
expiresAt: number;
|
|
@@ -53,9 +53,7 @@ export class RbacCacheService {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
if (!membership || membership.status !== MembershipStatus.ACTIVE) {
|
|
56
|
-
throw new ForbiddenException(
|
|
57
|
-
"Active organisation membership is required.",
|
|
58
|
-
);
|
|
56
|
+
throw new ForbiddenException('Active organisation membership is required.');
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
const value: RbacContext = {
|
|
@@ -123,7 +121,7 @@ export class RbacCacheService {
|
|
|
123
121
|
}
|
|
124
122
|
|
|
125
123
|
private ttlMs() {
|
|
126
|
-
return (this.config.get<number>(
|
|
124
|
+
return (this.config.get<number>('rbac.cacheTtlSeconds') ?? 60) * 1000;
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
private sweepExpiredEntries() {
|
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
import { ApiProperty, ApiPropertyOptional } from
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
2
|
|
|
3
3
|
export class PermissionResponseDto {
|
|
4
4
|
@ApiProperty({
|
|
5
|
-
example:
|
|
6
|
-
format:
|
|
5
|
+
example: '47bf2e11-6849-49cf-9138-f57c4e7ec0ac',
|
|
6
|
+
format: 'uuid',
|
|
7
7
|
})
|
|
8
8
|
id!: string;
|
|
9
9
|
|
|
10
|
-
@ApiProperty({ example:
|
|
10
|
+
@ApiProperty({ example: 'memberships.read' })
|
|
11
11
|
key!: string;
|
|
12
12
|
|
|
13
|
-
@ApiProperty({ example:
|
|
13
|
+
@ApiProperty({ example: 'memberships' })
|
|
14
14
|
module!: string;
|
|
15
15
|
|
|
16
|
-
@ApiProperty({ example:
|
|
16
|
+
@ApiProperty({ example: 'read' })
|
|
17
17
|
action!: string;
|
|
18
18
|
|
|
19
|
-
@ApiProperty({ example:
|
|
19
|
+
@ApiProperty({ example: 'Membership' })
|
|
20
20
|
subject!: string;
|
|
21
21
|
|
|
22
22
|
@ApiPropertyOptional({
|
|
23
|
-
example:
|
|
23
|
+
example: 'Read organisation memberships.',
|
|
24
24
|
nullable: true,
|
|
25
25
|
})
|
|
26
26
|
description?: string | null;
|
|
27
27
|
|
|
28
|
-
@ApiProperty({ example:
|
|
28
|
+
@ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
|
|
29
29
|
createdAt!: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export class RoutePermissionResponseDto {
|
|
33
|
-
@ApiProperty({ example:
|
|
33
|
+
@ApiProperty({ example: 'GET' })
|
|
34
34
|
method!: string;
|
|
35
35
|
|
|
36
|
-
@ApiProperty({ example:
|
|
36
|
+
@ApiProperty({ example: '/organisations/:orgId/memberships' })
|
|
37
37
|
path!: string;
|
|
38
38
|
|
|
39
|
-
@ApiProperty({ example: [
|
|
39
|
+
@ApiProperty({ example: ['memberships.read'], type: [String] })
|
|
40
40
|
permissions!: string[];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export class RoleResponseDto {
|
|
44
44
|
@ApiProperty({
|
|
45
|
-
example:
|
|
46
|
-
format:
|
|
45
|
+
example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
|
|
46
|
+
format: 'uuid',
|
|
47
47
|
})
|
|
48
48
|
id!: string;
|
|
49
49
|
|
|
50
50
|
@ApiProperty({
|
|
51
|
-
example:
|
|
52
|
-
format:
|
|
51
|
+
example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
|
|
52
|
+
format: 'uuid',
|
|
53
53
|
})
|
|
54
54
|
orgId!: string;
|
|
55
55
|
|
|
56
|
-
@ApiProperty({ example:
|
|
56
|
+
@ApiProperty({ example: 'Support' })
|
|
57
57
|
name!: string;
|
|
58
58
|
|
|
59
59
|
@ApiPropertyOptional({
|
|
60
|
-
example:
|
|
60
|
+
example: 'Can review support-facing records.',
|
|
61
61
|
nullable: true,
|
|
62
62
|
})
|
|
63
63
|
description?: string | null;
|
|
@@ -71,9 +71,21 @@ export class RoleResponseDto {
|
|
|
71
71
|
@ApiProperty({ type: [PermissionResponseDto] })
|
|
72
72
|
permissions!: PermissionResponseDto[];
|
|
73
73
|
|
|
74
|
-
@ApiProperty({ example:
|
|
74
|
+
@ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
|
|
75
75
|
createdAt!: string;
|
|
76
76
|
|
|
77
|
-
@ApiProperty({ example:
|
|
77
|
+
@ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
|
|
78
78
|
updatedAt!: string;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
export class RoleListResponseDto {
|
|
82
|
+
@ApiProperty({ type: [RoleResponseDto] })
|
|
83
|
+
items!: RoleResponseDto[];
|
|
84
|
+
|
|
85
|
+
@ApiPropertyOptional({
|
|
86
|
+
example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
|
|
87
|
+
format: 'uuid',
|
|
88
|
+
nullable: true,
|
|
89
|
+
})
|
|
90
|
+
nextCursor!: string | null;
|
|
91
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { ApiProperty, ApiPropertyOptional } from
|
|
2
|
-
import { Transform } from
|
|
3
|
-
import { IsOptional, IsString, MaxLength, MinLength } from
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Transform } from 'class-transformer';
|
|
3
|
+
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
|
4
4
|
|
|
5
5
|
export class CreateRoleDto {
|
|
6
|
-
@ApiProperty({ example:
|
|
7
|
-
@Transform(({ value }) => (typeof value ===
|
|
6
|
+
@ApiProperty({ example: 'Support' })
|
|
7
|
+
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
|
8
8
|
@IsString()
|
|
9
9
|
@MinLength(2)
|
|
10
10
|
@MaxLength(80)
|
|
11
11
|
name!: string;
|
|
12
12
|
|
|
13
|
-
@ApiPropertyOptional({ example:
|
|
13
|
+
@ApiPropertyOptional({ example: 'Can review support-facing records.' })
|
|
14
14
|
@IsOptional()
|
|
15
15
|
@IsString()
|
|
16
16
|
@MaxLength(240)
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import { ApiProperty } from
|
|
2
|
-
import {
|
|
3
|
-
ArrayMaxSize,
|
|
4
|
-
ArrayUnique,
|
|
5
|
-
IsArray,
|
|
6
|
-
IsString,
|
|
7
|
-
Matches,
|
|
8
|
-
MaxLength,
|
|
9
|
-
} from "class-validator";
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { ArrayMaxSize, ArrayUnique, IsArray, IsString, Matches, MaxLength } from 'class-validator';
|
|
10
3
|
|
|
11
4
|
export class UpdateRolePermissionsDto {
|
|
12
5
|
@ApiProperty({
|
|
13
|
-
example: [
|
|
6
|
+
example: ['memberships.read', 'settings.read'],
|
|
14
7
|
isArray: true,
|
|
15
8
|
})
|
|
16
9
|
@IsArray()
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { ApiPropertyOptional } from
|
|
2
|
-
import { Transform } from
|
|
3
|
-
import { IsOptional, IsString, MaxLength, MinLength } from
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Transform } from 'class-transformer';
|
|
3
|
+
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
|
4
4
|
|
|
5
5
|
export class UpdateRoleDto {
|
|
6
|
-
@ApiPropertyOptional({ example:
|
|
6
|
+
@ApiPropertyOptional({ example: 'Support Lead' })
|
|
7
7
|
@IsOptional()
|
|
8
|
-
@Transform(({ value }) => (typeof value ===
|
|
8
|
+
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
|
9
9
|
@IsString()
|
|
10
10
|
@MinLength(2)
|
|
11
11
|
@MaxLength(80)
|
|
12
12
|
name?: string;
|
|
13
13
|
|
|
14
|
-
@ApiPropertyOptional({ example:
|
|
14
|
+
@ApiPropertyOptional({ example: 'Can manage support-facing records.' })
|
|
15
15
|
@IsOptional()
|
|
16
16
|
@IsString()
|
|
17
17
|
@MaxLength(240)
|