@ftisindia/create-app 0.1.0

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 (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,478 @@
1
+ import {
2
+ BadRequestException,
3
+ ConflictException,
4
+ Injectable,
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";
16
+
17
+ const roleInclude = {
18
+ permissions: {
19
+ include: {
20
+ permission: true,
21
+ },
22
+ orderBy: {
23
+ permissionId: "asc",
24
+ },
25
+ },
26
+ _count: {
27
+ select: {
28
+ memberships: true,
29
+ },
30
+ },
31
+ } satisfies Prisma.RoleInclude;
32
+
33
+ type PrismaClient = Prisma.TransactionClient | PrismaService;
34
+
35
+ @Injectable()
36
+ export class AccessControlService {
37
+ constructor(
38
+ private readonly prisma: PrismaService,
39
+ private readonly requestContext: RequestContextService,
40
+ private readonly rbacCache: RbacCacheService,
41
+ ) {}
42
+
43
+ listPermissions() {
44
+ return this.prisma.permission.findMany({
45
+ orderBy: [{ module: "asc" }, { action: "asc" }],
46
+ });
47
+ }
48
+
49
+ listRoutePermissions() {
50
+ return routePermissionRegistry;
51
+ }
52
+
53
+ async listRoles(orgId: string) {
54
+ this.requestContext.assertOrgScope(orgId);
55
+
56
+ const roles = await this.prisma.role.findMany({
57
+ where: { orgId },
58
+ include: roleInclude,
59
+ orderBy: [{ isSystemSeeded: "desc" }, { name: "asc" }],
60
+ });
61
+
62
+ return roles.map(serializeRole);
63
+ }
64
+
65
+ async createRole(
66
+ currentUser: AuthenticatedUser,
67
+ orgId: string,
68
+ dto: CreateRoleDto,
69
+ ) {
70
+ this.requestContext.assertOrgScope(orgId);
71
+
72
+ const name = normalizeRoleName(dto.name);
73
+ assertValidRoleName(name);
74
+
75
+ try {
76
+ const role = await this.prisma.$transaction(async (tx) => {
77
+ const created = await tx.role.create({
78
+ data: {
79
+ orgId,
80
+ name,
81
+ description: normalizeOptionalString(dto.description),
82
+ },
83
+ include: roleInclude,
84
+ });
85
+
86
+ await this.writeAudit(tx, {
87
+ orgId,
88
+ actorUserId: currentUser.id,
89
+ action: "role.create",
90
+ targetId: created.id,
91
+ metadata: {
92
+ name: created.name,
93
+ description: created.description,
94
+ },
95
+ });
96
+
97
+ return created;
98
+ });
99
+
100
+ return serializeRole(role);
101
+ } catch (error) {
102
+ if (isPrismaUniqueError(error)) {
103
+ throw new ConflictException(
104
+ "Role name is already in use in this organisation.",
105
+ );
106
+ }
107
+
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ async updateRole(
113
+ currentUser: AuthenticatedUser,
114
+ orgId: string,
115
+ roleId: string,
116
+ dto: UpdateRoleDto,
117
+ ) {
118
+ this.requestContext.assertOrgScope(orgId);
119
+
120
+ const existing = await this.findRoleInOrg(this.prisma, orgId, roleId);
121
+ if (dto.name === undefined && dto.description === undefined) {
122
+ throw new BadRequestException(
123
+ "At least one role field must be provided.",
124
+ );
125
+ }
126
+
127
+ try {
128
+ const result = await this.prisma.$transaction(async (tx) => {
129
+ const nextName =
130
+ dto.name === undefined ? existing.name : normalizeRoleName(dto.name);
131
+ assertValidRoleName(nextName);
132
+ const nextDescription =
133
+ dto.description === undefined
134
+ ? existing.description
135
+ : normalizeOptionalString(dto.description);
136
+
137
+ if (
138
+ nextName === existing.name &&
139
+ nextDescription === existing.description
140
+ ) {
141
+ const role = await tx.role.findUniqueOrThrow({
142
+ where: { id: existing.id },
143
+ include: roleInclude,
144
+ });
145
+
146
+ return { role, changed: false };
147
+ }
148
+
149
+ const updated = await tx.role.update({
150
+ where: { id: existing.id },
151
+ data: {
152
+ name: nextName,
153
+ description: nextDescription,
154
+ },
155
+ include: roleInclude,
156
+ });
157
+
158
+ await this.writeAudit(tx, {
159
+ orgId,
160
+ actorUserId: currentUser.id,
161
+ action: "role.update",
162
+ targetId: updated.id,
163
+ metadata: {
164
+ previousName: existing.name,
165
+ nextName: updated.name,
166
+ previousDescription: existing.description,
167
+ nextDescription: updated.description,
168
+ },
169
+ });
170
+
171
+ return { role: updated, changed: true };
172
+ });
173
+
174
+ if (result.changed) {
175
+ await this.rbacCache.invalidateRole(orgId, roleId);
176
+ }
177
+
178
+ return serializeRole(result.role);
179
+ } catch (error) {
180
+ if (isPrismaUniqueError(error)) {
181
+ throw new ConflictException(
182
+ "Role name is already in use in this organisation.",
183
+ );
184
+ }
185
+
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ async deleteRole(
191
+ currentUser: AuthenticatedUser,
192
+ orgId: string,
193
+ roleId: string,
194
+ ) {
195
+ this.requestContext.assertOrgScope(orgId);
196
+
197
+ const existing = await this.findRoleInOrg(this.prisma, orgId, roleId);
198
+ if (existing.isSystemSeeded) {
199
+ throw new ConflictException("System-seeded roles cannot be deleted.");
200
+ }
201
+
202
+ try {
203
+ await this.prisma.$transaction(async (tx) => {
204
+ const membershipCount = await tx.membership.count({
205
+ where: {
206
+ orgId,
207
+ roleId,
208
+ },
209
+ });
210
+ if (membershipCount > 0) {
211
+ throw new ConflictException(
212
+ "Role cannot be deleted while memberships still use it.",
213
+ );
214
+ }
215
+
216
+ const invitationCount = await tx.invitation.count({
217
+ where: {
218
+ orgId,
219
+ roleId,
220
+ },
221
+ });
222
+ if (invitationCount > 0) {
223
+ throw new ConflictException(
224
+ "Role cannot be deleted while invitations reference it.",
225
+ );
226
+ }
227
+
228
+ await tx.rolePermission.deleteMany({
229
+ where: { roleId },
230
+ });
231
+
232
+ await tx.role.delete({
233
+ where: { id: roleId },
234
+ });
235
+
236
+ await this.writeAudit(tx, {
237
+ orgId,
238
+ actorUserId: currentUser.id,
239
+ action: "role.delete",
240
+ targetId: roleId,
241
+ metadata: {
242
+ name: existing.name,
243
+ },
244
+ });
245
+ });
246
+ } catch (error) {
247
+ if (isPrismaForeignKeyError(error)) {
248
+ throw new ConflictException(
249
+ "Role cannot be deleted while other records reference it.",
250
+ );
251
+ }
252
+
253
+ throw error;
254
+ }
255
+
256
+ await this.rbacCache.invalidateRole(orgId, roleId);
257
+ return { deleted: true };
258
+ }
259
+
260
+ async listRolePermissions(orgId: string, roleId: string) {
261
+ this.requestContext.assertOrgScope(orgId);
262
+
263
+ const role = await this.prisma.role.findUnique({
264
+ where: {
265
+ id_orgId: {
266
+ id: roleId,
267
+ orgId,
268
+ },
269
+ },
270
+ include: roleInclude,
271
+ });
272
+
273
+ if (!role) {
274
+ throw new NotFoundException("Role was not found in this organisation.");
275
+ }
276
+
277
+ return serializeRole(role);
278
+ }
279
+
280
+ async replaceRolePermissions(
281
+ currentUser: AuthenticatedUser,
282
+ orgId: string,
283
+ roleId: string,
284
+ dto: UpdateRolePermissionsDto,
285
+ ) {
286
+ this.requestContext.assertOrgScope(orgId);
287
+
288
+ const role = await this.findRoleInOrg(this.prisma, orgId, roleId);
289
+ const permissionKeys = [...dto.permissionKeys].sort();
290
+
291
+ try {
292
+ const result = await this.prisma.$transaction(async (tx) => {
293
+ const permissions = await tx.permission.findMany({
294
+ where: {
295
+ key: {
296
+ in: permissionKeys,
297
+ },
298
+ },
299
+ select: {
300
+ id: true,
301
+ key: true,
302
+ },
303
+ });
304
+ const foundKeys = new Set(
305
+ permissions.map((permission) => permission.key),
306
+ );
307
+ const missingKeys = permissionKeys.filter((key) => !foundKeys.has(key));
308
+ if (missingKeys.length > 0) {
309
+ throw new NotFoundException(
310
+ `Unknown permission keys: ${missingKeys.join(", ")}`,
311
+ );
312
+ }
313
+
314
+ const previousPermissions = await tx.rolePermission.findMany({
315
+ where: { roleId },
316
+ include: { permission: true },
317
+ });
318
+ const previousPermissionKeys = previousPermissions
319
+ .map((rolePermission) => rolePermission.permission.key)
320
+ .sort();
321
+
322
+ if (arraysEqual(previousPermissionKeys, permissionKeys)) {
323
+ const unchanged = await tx.role.findUniqueOrThrow({
324
+ where: { id: roleId },
325
+ include: roleInclude,
326
+ });
327
+
328
+ return { role: unchanged, changed: false };
329
+ }
330
+
331
+ await tx.rolePermission.deleteMany({
332
+ where: { roleId },
333
+ });
334
+
335
+ if (permissions.length > 0) {
336
+ await tx.rolePermission.createMany({
337
+ data: permissions.map((permission) => ({
338
+ roleId,
339
+ permissionId: permission.id,
340
+ })),
341
+ skipDuplicates: true,
342
+ });
343
+ }
344
+
345
+ await this.writeAudit(tx, {
346
+ orgId,
347
+ actorUserId: currentUser.id,
348
+ action: "role.permissions.replace",
349
+ targetId: roleId,
350
+ metadata: {
351
+ roleName: role.name,
352
+ previousPermissionKeys,
353
+ nextPermissionKeys: permissionKeys,
354
+ },
355
+ });
356
+
357
+ const updated = await tx.role.findUniqueOrThrow({
358
+ where: { id: roleId },
359
+ include: roleInclude,
360
+ });
361
+
362
+ return { role: updated, changed: true };
363
+ });
364
+
365
+ if (result.changed) {
366
+ await this.rbacCache.invalidateRole(orgId, roleId);
367
+ }
368
+
369
+ return serializeRole(result.role);
370
+ } catch (error) {
371
+ if (isPrismaForeignKeyError(error)) {
372
+ throw new ConflictException(
373
+ "Role permissions could not be updated because referenced data changed.",
374
+ );
375
+ }
376
+
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ private async findRoleInOrg<
382
+ TInclude extends Prisma.RoleInclude | undefined = undefined,
383
+ >(client: PrismaClient, orgId: string, roleId: string, include?: TInclude) {
384
+ const role = await client.role.findUnique({
385
+ where: {
386
+ id_orgId: {
387
+ id: roleId,
388
+ orgId,
389
+ },
390
+ },
391
+ include,
392
+ });
393
+
394
+ if (!role) {
395
+ throw new NotFoundException("Role was not found in this organisation.");
396
+ }
397
+
398
+ return role;
399
+ }
400
+
401
+ private async writeAudit(
402
+ client: PrismaClient,
403
+ data: {
404
+ orgId: string;
405
+ actorUserId: string;
406
+ action: string;
407
+ targetId: string;
408
+ metadata?: Prisma.InputJsonObject;
409
+ },
410
+ ) {
411
+ await client.auditLog.create({
412
+ data: {
413
+ orgId: data.orgId,
414
+ actorUserId: data.actorUserId,
415
+ action: data.action,
416
+ targetType: "Role",
417
+ targetId: data.targetId,
418
+ metadata: data.metadata,
419
+ },
420
+ });
421
+ }
422
+ }
423
+
424
+ function arraysEqual(left: string[], right: string[]) {
425
+ return (
426
+ left.length === right.length &&
427
+ left.every((value, index) => value === right[index])
428
+ );
429
+ }
430
+
431
+ function normalizeRoleName(name: string) {
432
+ return name.trim();
433
+ }
434
+
435
+ function assertValidRoleName(name: string) {
436
+ if (name.length < 2) {
437
+ throw new BadRequestException(
438
+ "Role name must contain at least 2 non-whitespace characters.",
439
+ );
440
+ }
441
+ }
442
+
443
+ function normalizeOptionalString(value: string | undefined) {
444
+ const normalized = value?.trim();
445
+ return normalized || null;
446
+ }
447
+
448
+ function isPrismaUniqueError(error: unknown) {
449
+ return (
450
+ error instanceof Prisma.PrismaClientKnownRequestError &&
451
+ error.code === "P2002"
452
+ );
453
+ }
454
+
455
+ function isPrismaForeignKeyError(error: unknown) {
456
+ return (
457
+ error instanceof Prisma.PrismaClientKnownRequestError &&
458
+ error.code === "P2003"
459
+ );
460
+ }
461
+
462
+ function serializeRole(
463
+ role: Prisma.RoleGetPayload<{ include: typeof roleInclude }>,
464
+ ) {
465
+ return {
466
+ id: role.id,
467
+ orgId: role.orgId,
468
+ name: role.name,
469
+ description: role.description,
470
+ isSystemSeeded: role.isSystemSeeded,
471
+ membershipCount: role._count.memberships,
472
+ permissions: role.permissions
473
+ .map((rolePermission) => rolePermission.permission)
474
+ .sort((a, b) => a.key.localeCompare(b.key)),
475
+ createdAt: role.createdAt,
476
+ updatedAt: role.updatedAt,
477
+ };
478
+ }
@@ -0,0 +1,77 @@
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";
15
+
16
+ type RequestWithUser = {
17
+ orgId?: string;
18
+ params?: Record<string, string | undefined>;
19
+ rbac?: unknown;
20
+ user?: AuthenticatedUser;
21
+ };
22
+
23
+ @Injectable()
24
+ export class PermissionGuard implements CanActivate {
25
+ constructor(
26
+ private readonly abilityFactory: AbilityFactory,
27
+ private readonly reflector: Reflector,
28
+ private readonly requestContext: RequestContextService,
29
+ private readonly rbacCache: RbacCacheService,
30
+ ) {}
31
+
32
+ async canActivate(context: ExecutionContext) {
33
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
34
+ context.getHandler(),
35
+ context.getClass(),
36
+ ]);
37
+ if (isPublic) {
38
+ return true;
39
+ }
40
+
41
+ const required = this.reflector.getAllAndOverride<PermissionKey[]>(
42
+ REQUIRED_PERMISSIONS_KEY,
43
+ [context.getHandler(), context.getClass()],
44
+ );
45
+ if (!required || required.length === 0) {
46
+ throw new ForbiddenException("Permission metadata is required.");
47
+ }
48
+
49
+ const request = context.switchToHttp().getRequest<RequestWithUser>();
50
+ const user = request.user;
51
+ const orgId = request.orgId ?? request.params?.orgId;
52
+
53
+ if (!user || !orgId) {
54
+ throw new ForbiddenException("Active organisation context is required.");
55
+ }
56
+
57
+ const rbac = await this.rbacCache.getContext(user.id, orgId);
58
+ const ability = this.abilityFactory.createForContext(rbac);
59
+
60
+ const allowed = required.every((permission) => {
61
+ const { action, subject } = permissionKeyToRule(permission);
62
+ return ability.can(action, subject);
63
+ });
64
+
65
+ if (!allowed) {
66
+ throw new ForbiddenException("Required permission is missing.");
67
+ }
68
+
69
+ request.rbac = rbac;
70
+ this.requestContext.merge({
71
+ userId: user.id,
72
+ orgId,
73
+ rbac,
74
+ });
75
+ return true;
76
+ }
77
+ }
@@ -0,0 +1,148 @@
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
+
7
+ type CacheEntry = {
8
+ expiresAt: number;
9
+ value: RbacContext;
10
+ };
11
+
12
+ const MAX_CACHE_ENTRIES = 5000;
13
+
14
+ @Injectable()
15
+ export class RbacCacheService {
16
+ private readonly cache = new Map<string, CacheEntry>();
17
+
18
+ constructor(
19
+ private readonly config: ConfigService,
20
+ private readonly prisma: PrismaService,
21
+ ) {}
22
+
23
+ async getContext(userId: string, orgId: string) {
24
+ const key = this.key(userId, orgId);
25
+ const cached = this.cache.get(key);
26
+ if (cached && cached.expiresAt > Date.now()) {
27
+ return cached.value;
28
+ }
29
+
30
+ if (cached) {
31
+ this.cache.delete(key);
32
+ }
33
+ this.sweepExpiredEntries();
34
+
35
+ const membership = await this.prisma.membership.findUnique({
36
+ where: {
37
+ userId_orgId: {
38
+ userId,
39
+ orgId,
40
+ },
41
+ },
42
+ include: {
43
+ role: {
44
+ include: {
45
+ permissions: {
46
+ include: {
47
+ permission: true,
48
+ },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ });
54
+
55
+ if (!membership || membership.status !== MembershipStatus.ACTIVE) {
56
+ throw new ForbiddenException(
57
+ "Active organisation membership is required.",
58
+ );
59
+ }
60
+
61
+ const value: RbacContext = {
62
+ cacheKey: key,
63
+ userId,
64
+ orgId,
65
+ membershipId: membership.id,
66
+ membershipStatus: membership.status,
67
+ roleId: membership.roleId,
68
+ isOwner: membership.isOwner,
69
+ isBillingContact: membership.isBillingContact,
70
+ permissionKeys: membership.role.permissions.map(
71
+ (rolePermission) => rolePermission.permission.key,
72
+ ),
73
+ };
74
+
75
+ this.cache.set(key, {
76
+ value,
77
+ expiresAt: Date.now() + this.ttlMs(),
78
+ });
79
+ this.enforceEntryLimit();
80
+
81
+ return value;
82
+ }
83
+
84
+ key(userId: string, orgId: string) {
85
+ return `rbac:${userId}:${orgId}`;
86
+ }
87
+
88
+ invalidate(userId: string, orgId: string) {
89
+ this.cache.delete(this.key(userId, orgId));
90
+ }
91
+
92
+ invalidateMany(items: Array<{ userId: string; orgId: string }>) {
93
+ for (const item of items) {
94
+ this.invalidate(item.userId, item.orgId);
95
+ }
96
+ }
97
+
98
+ async invalidateRole(orgId: string, roleId: string) {
99
+ const memberships = await this.prisma.membership.findMany({
100
+ where: {
101
+ orgId,
102
+ roleId,
103
+ },
104
+ select: {
105
+ userId: true,
106
+ orgId: true,
107
+ },
108
+ });
109
+
110
+ this.invalidateMany(memberships);
111
+ }
112
+
113
+ async invalidateOrg(orgId: string) {
114
+ const memberships = await this.prisma.membership.findMany({
115
+ where: { orgId },
116
+ select: {
117
+ userId: true,
118
+ orgId: true,
119
+ },
120
+ });
121
+
122
+ this.invalidateMany(memberships);
123
+ }
124
+
125
+ private ttlMs() {
126
+ return (this.config.get<number>("rbac.cacheTtlSeconds") ?? 60) * 1000;
127
+ }
128
+
129
+ private sweepExpiredEntries() {
130
+ const now = Date.now();
131
+ for (const [key, entry] of this.cache) {
132
+ if (entry.expiresAt <= now) {
133
+ this.cache.delete(key);
134
+ }
135
+ }
136
+ }
137
+
138
+ private enforceEntryLimit() {
139
+ while (this.cache.size > MAX_CACHE_ENTRIES) {
140
+ const oldestKey = this.cache.keys().next().value as string | undefined;
141
+ if (!oldestKey) {
142
+ return;
143
+ }
144
+
145
+ this.cache.delete(oldestKey);
146
+ }
147
+ }
148
+ }