@ftisindia/create-app 0.1.3 → 0.1.5

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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +6 -0
  3. package/template/README.md +11 -1
  4. package/template/_package.json +0 -2
  5. package/template/docs/API_REFERENCE.md +13 -0
  6. package/template/docs/OAUTH.md +7 -3
  7. package/template/scripts/gen-module.mjs +2 -0
  8. package/template/src/app.module.ts +16 -22
  9. package/template/src/common/dto/error-response.dto.ts +3 -3
  10. package/template/src/common/dto/membership-response.dto.ts +26 -14
  11. package/template/src/common/dto/mutation-response.dto.ts +1 -1
  12. package/template/src/common/dto/pagination-query.dto.ts +37 -0
  13. package/template/src/common/dto/role-summary.dto.ts +5 -5
  14. package/template/src/common/dto/user-summary.dto.ts +6 -6
  15. package/template/src/common/filters/http-exception.filter.ts +9 -19
  16. package/template/src/common/swagger/api-error-responses.ts +12 -12
  17. package/template/src/config/app.config.ts +8 -3
  18. package/template/src/config/auth.config.ts +3 -3
  19. package/template/src/config/database.config.ts +3 -3
  20. package/template/src/config/env.validation.ts +78 -40
  21. package/template/src/config/index.ts +5 -5
  22. package/template/src/config/rbac.config.ts +3 -3
  23. package/template/src/database/prisma/prisma-transaction.ts +1 -1
  24. package/template/src/database/prisma/prisma.module.ts +2 -2
  25. package/template/src/database/prisma/prisma.service.ts +3 -6
  26. package/template/src/main.ts +24 -11
  27. package/template/src/modules/access-control/access-control.module.ts +11 -10
  28. package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
  29. package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
  30. package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
  31. package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
  32. package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
  33. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
  34. package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
  35. package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
  36. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
  37. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
  38. package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
  39. package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
  40. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  41. package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
  42. package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
  43. package/template/src/modules/access-control/types/permission-key.ts +19 -19
  44. package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
  45. package/template/src/modules/audit/application/services/audit.service.ts +7 -7
  46. package/template/src/modules/audit/audit.module.ts +4 -4
  47. package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
  48. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
  49. package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
  50. package/template/src/modules/auth/application/services/auth.service.ts +147 -110
  51. package/template/src/modules/auth/application/services/password.service.ts +2 -2
  52. package/template/src/modules/auth/application/services/token.service.ts +20 -21
  53. package/template/src/modules/auth/auth.module.ts +20 -47
  54. package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
  55. package/template/src/modules/auth/dto/login.dto.ts +4 -4
  56. package/template/src/modules/auth/dto/logout.dto.ts +1 -1
  57. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
  58. package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
  59. package/template/src/modules/auth/dto/signup.dto.ts +5 -11
  60. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
  61. package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
  62. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
  63. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
  64. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
  65. package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
  66. package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
  67. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
  68. package/template/src/modules/health/dto/health-response.dto.ts +5 -5
  69. package/template/src/modules/health/health.module.ts +2 -2
  70. package/template/src/modules/health/presentation/health.controller.ts +13 -13
  71. package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
  72. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
  73. package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
  74. package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
  75. package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
  76. package/template/src/modules/invitations/invitations.module.ts +5 -5
  77. package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
  78. package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
  79. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
  80. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
  81. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
  82. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
  83. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
  84. package/template/src/modules/memberships/memberships.module.ts +4 -4
  85. package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
  86. package/template/src/modules/organisations/application/services/organisations.service.ts +87 -23
  87. package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
  88. package/template/src/modules/organisations/dto/organisation-response.dto.ts +65 -14
  89. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
  90. package/template/src/modules/organisations/organisations.module.ts +5 -5
  91. package/template/src/modules/organisations/presentation/organisations.controller.ts +31 -18
  92. package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
  93. package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
  94. package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
  95. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
  96. package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
  97. package/template/src/modules/request-context/request-context.module.ts +7 -7
  98. package/template/src/modules/request-context/types/request-context.ts +2 -2
  99. package/template/src/modules/sample/application/services/sample.service.ts +10 -8
  100. package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
  101. package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
  102. package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
  103. package/template/src/modules/sample/sample.module.ts +4 -4
  104. package/template/src/modules/settings/application/services/settings.service.ts +15 -27
  105. package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
  106. package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
  107. package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
  108. package/template/src/modules/settings/settings.module.ts +5 -5
  109. package/template/src/modules/settings/types/setting-definitions.ts +49 -33
  110. package/template/test/auth-refresh.spec.ts +90 -0
  111. package/template/test/frontend-bootstrap.spec.ts +181 -0
  112. package/template/test/role-permission-policy.spec.ts +94 -0
  113. package/template/test/route-registry.validator.spec.ts +12 -0
  114. package/template/test/security.e2e-spec.ts +134 -2
@@ -3,16 +3,23 @@ import {
3
3
  ConflictException,
4
4
  Injectable,
5
5
  NotFoundException,
6
- } from "@nestjs/common";
7
- import { Prisma } from "@prisma/client";
8
- import { PrismaService } from "../../../../database/prisma/prisma.service";
9
- import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
10
- import { RequestContextService } from "../../../request-context/application/services/request-context.service";
11
- import { CreateRoleDto } from "../../dto/create-role.dto";
12
- import { UpdateRolePermissionsDto } from "../../dto/update-role-permissions.dto";
13
- import { UpdateRoleDto } from "../../dto/update-role.dto";
14
- import { routePermissionRegistry } from "../../types/route-permission-registry";
15
- import { RbacCacheService } from "./rbac-cache.service";
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: "asc",
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: "asc" }, { action: "asc" }],
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: "desc" }, { name: "asc" }],
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: "role.create",
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: "role.update",
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("System-seeded roles cannot be deleted.");
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: "role.delete",
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("Role was not found in this organisation.");
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: "role.permissions.replace",
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
- "Role permissions could not be updated because referenced data changed.",
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
- TInclude extends Prisma.RoleInclude | undefined = undefined,
383
- >(client: PrismaClient, orgId: string, roleId: string, include?: TInclude) {
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("Role was not found in this organisation.");
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: "Role",
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
- CanActivate,
3
- ExecutionContext,
4
- ForbiddenException,
5
- Injectable,
6
- } from "@nestjs/common";
7
- import { Reflector } from "@nestjs/core";
8
- import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
9
- import { RequestContextService } from "../../../request-context/application/services/request-context.service";
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
- REQUIRED_PERMISSIONS_KEY,
43
- [context.getHandler(), context.getClass()],
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("Permission metadata is required.");
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("Active organisation context is required.");
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("Required permission is missing.");
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 "@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";
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>("rbac.cacheTtlSeconds") ?? 60) * 1000;
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 "@nestjs/swagger";
1
+ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2
2
 
3
3
  export class PermissionResponseDto {
4
4
  @ApiProperty({
5
- example: "47bf2e11-6849-49cf-9138-f57c4e7ec0ac",
6
- format: "uuid",
5
+ example: '47bf2e11-6849-49cf-9138-f57c4e7ec0ac',
6
+ format: 'uuid',
7
7
  })
8
8
  id!: string;
9
9
 
10
- @ApiProperty({ example: "memberships.read" })
10
+ @ApiProperty({ example: 'memberships.read' })
11
11
  key!: string;
12
12
 
13
- @ApiProperty({ example: "memberships" })
13
+ @ApiProperty({ example: 'memberships' })
14
14
  module!: string;
15
15
 
16
- @ApiProperty({ example: "read" })
16
+ @ApiProperty({ example: 'read' })
17
17
  action!: string;
18
18
 
19
- @ApiProperty({ example: "Membership" })
19
+ @ApiProperty({ example: 'Membership' })
20
20
  subject!: string;
21
21
 
22
22
  @ApiPropertyOptional({
23
- example: "Read organisation memberships.",
23
+ example: 'Read organisation memberships.',
24
24
  nullable: true,
25
25
  })
26
26
  description?: string | null;
27
27
 
28
- @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
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: "GET" })
33
+ @ApiProperty({ example: 'GET' })
34
34
  method!: string;
35
35
 
36
- @ApiProperty({ example: "/organisations/:orgId/memberships" })
36
+ @ApiProperty({ example: '/organisations/:orgId/memberships' })
37
37
  path!: string;
38
38
 
39
- @ApiProperty({ example: ["memberships.read"], type: [String] })
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: "f602c057-04f4-4ef8-8c84-1b7c62fbf8c5",
46
- format: "uuid",
45
+ example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
46
+ format: 'uuid',
47
47
  })
48
48
  id!: string;
49
49
 
50
50
  @ApiProperty({
51
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
52
- format: "uuid",
51
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
52
+ format: 'uuid',
53
53
  })
54
54
  orgId!: string;
55
55
 
56
- @ApiProperty({ example: "Support" })
56
+ @ApiProperty({ example: 'Support' })
57
57
  name!: string;
58
58
 
59
59
  @ApiPropertyOptional({
60
- example: "Can review support-facing records.",
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: "2026-06-01T10:30:00.000Z", format: "date-time" })
74
+ @ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
75
75
  createdAt!: string;
76
76
 
77
- @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
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 "@nestjs/swagger";
2
- import { Transform } from "class-transformer";
3
- import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
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: "Support" })
7
- @Transform(({ value }) => (typeof value === "string" ? value.trim() : 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: "Can review support-facing records." })
13
+ @ApiPropertyOptional({ example: 'Can review support-facing records.' })
14
14
  @IsOptional()
15
15
  @IsString()
16
16
  @MaxLength(240)
@@ -0,0 +1,31 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { PermissionKey, permissionKeys } from '../types/permission-key';
3
+
4
+ export class CurrentAccessControlResponseDto {
5
+ @ApiProperty({
6
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
7
+ format: 'uuid',
8
+ })
9
+ orgId!: string;
10
+
11
+ @ApiProperty({
12
+ example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
13
+ format: 'uuid',
14
+ })
15
+ membershipId!: string;
16
+
17
+ @ApiProperty({
18
+ example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
19
+ format: 'uuid',
20
+ })
21
+ roleId!: string;
22
+
23
+ @ApiProperty({ example: true })
24
+ isOwner!: boolean;
25
+
26
+ @ApiProperty({ example: true })
27
+ isBillingContact!: boolean;
28
+
29
+ @ApiProperty({ enum: permissionKeys, example: ['organisations.read', 'settings.read'] })
30
+ permissionKeys!: PermissionKey[];
31
+ }
@@ -1,16 +1,9 @@
1
- import { ApiProperty } from "@nestjs/swagger";
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: ["memberships.read", "settings.read"],
6
+ example: ['memberships.read', 'settings.read'],
14
7
  isArray: true,
15
8
  })
16
9
  @IsArray()
@@ -1,17 +1,17 @@
1
- import { ApiPropertyOptional } from "@nestjs/swagger";
2
- import { Transform } from "class-transformer";
3
- import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
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: "Support Lead" })
6
+ @ApiPropertyOptional({ example: 'Support Lead' })
7
7
  @IsOptional()
8
- @Transform(({ value }) => (typeof value === "string" ? value.trim() : 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: "Can manage support-facing records." })
14
+ @ApiPropertyOptional({ example: 'Can manage support-facing records.' })
15
15
  @IsOptional()
16
16
  @IsString()
17
17
  @MaxLength(240)