@lenne.tech/nest-server 11.17.0 → 11.18.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 (91) hide show
  1. package/dist/config.env.js +2 -2
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
  4. package/dist/core/common/decorators/response-model.decorator.js +8 -0
  5. package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
  6. package/dist/core/common/helpers/db.helper.js +2 -2
  7. package/dist/core/common/helpers/db.helper.js.map +1 -1
  8. package/dist/core/common/helpers/filter.helper.js +3 -3
  9. package/dist/core/common/helpers/filter.helper.js.map +1 -1
  10. package/dist/core/common/helpers/input.helper.js +2 -2
  11. package/dist/core/common/helpers/input.helper.js.map +1 -1
  12. package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
  13. package/dist/core/common/helpers/interceptor.helper.js +84 -0
  14. package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
  15. package/dist/core/common/helpers/service.helper.d.ts +1 -0
  16. package/dist/core/common/helpers/service.helper.js +1 -0
  17. package/dist/core/common/helpers/service.helper.js.map +1 -1
  18. package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
  19. package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
  22. package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
  23. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
  24. package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
  25. package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
  26. package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
  27. package/dist/core/common/interfaces/server-options.interface.d.ts +14 -0
  28. package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
  29. package/dist/core/common/middleware/request-context.middleware.js +29 -0
  30. package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
  31. package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
  32. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  33. package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
  34. package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
  35. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
  36. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
  37. package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
  38. package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
  39. package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
  40. package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
  41. package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
  42. package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
  43. package/dist/core/common/services/config.service.js +2 -2
  44. package/dist/core/common/services/config.service.js.map +1 -1
  45. package/dist/core/common/services/model-registry.service.d.ts +8 -0
  46. package/dist/core/common/services/model-registry.service.js +20 -0
  47. package/dist/core/common/services/model-registry.service.js.map +1 -0
  48. package/dist/core/common/services/module.service.d.ts +2 -0
  49. package/dist/core/common/services/module.service.js +36 -1
  50. package/dist/core/common/services/module.service.js.map +1 -1
  51. package/dist/core/common/services/request-context.service.d.ts +18 -0
  52. package/dist/core/common/services/request-context.service.js +32 -0
  53. package/dist/core/common/services/request-context.service.js.map +1 -0
  54. package/dist/core/modules/auth/guards/auth.guard.js +2 -2
  55. package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
  56. package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
  57. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  58. package/dist/core.module.js +36 -0
  59. package/dist/core.module.js.map +1 -1
  60. package/dist/index.d.ts +10 -0
  61. package/dist/index.js +10 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/server/modules/file/file-info.model.d.ts +12 -12
  64. package/dist/server/modules/user/user.model.d.ts +33 -33
  65. package/dist/tsconfig.build.tsbuildinfo +1 -1
  66. package/package.json +35 -30
  67. package/src/config.env.ts +2 -2
  68. package/src/core/common/decorators/response-model.decorator.ts +31 -0
  69. package/src/core/common/helpers/db.helper.ts +2 -2
  70. package/src/core/common/helpers/filter.helper.ts +3 -3
  71. package/src/core/common/helpers/input.helper.ts +2 -2
  72. package/src/core/common/helpers/interceptor.helper.ts +132 -0
  73. package/src/core/common/helpers/service.helper.ts +1 -1
  74. package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
  75. package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
  76. package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
  77. package/src/core/common/interfaces/server-options.interface.ts +160 -0
  78. package/src/core/common/middleware/request-context.middleware.ts +25 -0
  79. package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
  80. package/src/core/common/plugins/complexity.plugin.ts +2 -2
  81. package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
  82. package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
  83. package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
  84. package/src/core/common/services/config.service.ts +2 -2
  85. package/src/core/common/services/model-registry.service.ts +25 -0
  86. package/src/core/common/services/module.service.ts +91 -1
  87. package/src/core/common/services/request-context.service.ts +69 -0
  88. package/src/core/modules/auth/guards/auth.guard.ts +2 -2
  89. package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
  90. package/src/core.module.ts +55 -4
  91. package/src/index.ts +10 -0
@@ -1426,6 +1426,26 @@ export interface IServerOptions {
1426
1426
  * default = true
1427
1427
  */
1428
1428
  noteCheckedObjects?: boolean;
1429
+
1430
+ /**
1431
+ * Whether to remove known secret fields as a fallback safety net.
1432
+ * Applied after securityCheck() to catch any remaining secrets.
1433
+ * default = true
1434
+ *
1435
+ * @since 11.18.0
1436
+ */
1437
+ removeSecretFields?: boolean;
1438
+
1439
+ /**
1440
+ * List of field names to remove in the fallback secret removal.
1441
+ * Only used when removeSecretFields is true.
1442
+ * Note: security.secretFields (global) takes precedence over this setting if both are defined.
1443
+ * Use this for interceptor-specific overrides, or security.secretFields for a global default.
1444
+ * default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
1445
+ *
1446
+ * @since 11.18.0
1447
+ */
1448
+ secretFields?: string[];
1429
1449
  };
1430
1450
 
1431
1451
  /**
@@ -1449,6 +1469,146 @@ export interface IServerOptions {
1449
1469
  */
1450
1470
  nonWhitelistedFields?: 'strip' | 'error' | false;
1451
1471
  };
1472
+
1473
+ /**
1474
+ * Mongoose password hashing plugin - automatically hashes passwords on save/update
1475
+ * (save, findOneAndUpdate, updateOne, updateMany).
1476
+ * Prevents plaintext passwords even when CrudService.process() is bypassed.
1477
+ * Already-hashed values (BCrypt pattern /^\$2[aby]\$\d+\$/) are detected and skipped
1478
+ * to prevent double-hashing.
1479
+ *
1480
+ * For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
1481
+ * skipPatterns so they are preserved as-is and not hashed.
1482
+ *
1483
+ * default = true (opt-out via false)
1484
+ *
1485
+ * @example
1486
+ * ```typescript
1487
+ * // Default: hash all passwords
1488
+ * mongoosePasswordPlugin: true,
1489
+ *
1490
+ * // Skip sentinel values used for account locking
1491
+ * mongoosePasswordPlugin: { skipPatterns: ['^!LOCKED:'] },
1492
+ *
1493
+ * // Disable entirely
1494
+ * mongoosePasswordPlugin: false,
1495
+ * ```
1496
+ *
1497
+ * @since 11.18.0
1498
+ */
1499
+ mongoosePasswordPlugin?:
1500
+ | boolean
1501
+ | {
1502
+ /**
1503
+ * Regex patterns for password values that should NOT be hashed.
1504
+ * Use this for sentinel/lock values like '!LOCKED:REQUIRES_PASSWORD_RESET'
1505
+ * that are intentionally invalid hashes to prevent login.
1506
+ * Patterns are matched against the raw password value.
1507
+ *
1508
+ * @example ['^!LOCKED:', '^\\$NOHASH\\$']
1509
+ */
1510
+ skipPatterns?: (string | RegExp)[];
1511
+ };
1512
+
1513
+ /**
1514
+ * Mongoose audit fields plugin - automatically sets createdBy/updatedBy fields
1515
+ * on save/update operations.
1516
+ * Uses RequestContext (AsyncLocalStorage) to access the current user —
1517
+ * requires RequestContextMiddleware to be active (registered automatically by CoreModule).
1518
+ * Only activates on schemas that have createdBy and/or updatedBy fields defined.
1519
+ * default = true (opt-out via false)
1520
+ *
1521
+ * @since 11.18.0
1522
+ */
1523
+ mongooseAuditFieldsPlugin?: boolean;
1524
+
1525
+ /**
1526
+ * Mongoose role guard plugin - prevents unauthorized users from escalating roles
1527
+ * via save/update operations.
1528
+ * Uses RequestContext (AsyncLocalStorage) to access the current user —
1529
+ * requires RequestContextMiddleware to be active (registered automatically by CoreModule).
1530
+ * System operations without a request context (e.g. migrations) are allowed through.
1531
+ *
1532
+ * By default, only ADMIN users can assign roles. Use allowedRoles to permit
1533
+ * additional roles (e.g. ORGA, COMPANY_ADMIN) to assign roles to other users.
1534
+ *
1535
+ * default = true (opt-out via false)
1536
+ *
1537
+ * @example
1538
+ * ```typescript
1539
+ * // Default: only ADMIN can assign roles
1540
+ * mongooseRoleGuardPlugin: true,
1541
+ *
1542
+ * // Allow ADMIN and custom roles to assign roles
1543
+ * mongooseRoleGuardPlugin: { allowedRoles: ['ORGA', 'COMPANY_ADMIN'] },
1544
+ *
1545
+ * // Disable entirely
1546
+ * mongooseRoleGuardPlugin: false,
1547
+ * ```
1548
+ *
1549
+ * @since 11.18.0
1550
+ */
1551
+ mongooseRoleGuardPlugin?:
1552
+ | boolean
1553
+ | {
1554
+ /**
1555
+ * Additional roles (beyond ADMIN) that are allowed to assign roles to other users.
1556
+ * ADMIN is always implicitly allowed — no need to include it here.
1557
+ */
1558
+ allowedRoles?: string[];
1559
+ };
1560
+
1561
+ /**
1562
+ * ResponseModelInterceptor - auto-converts plain objects/Mongoose documents
1563
+ * to model instances with securityCheck() and correct constructor metadata.
1564
+ * Ensures @Restricted field filtering and securityCheck() work even when
1565
+ * CrudService.process() is bypassed.
1566
+ *
1567
+ * Model class resolution order:
1568
+ * 1. Explicit @ResponseModel(ModelClass) decorator
1569
+ * 2. GraphQL TypeMetadataStorage (automatic for @Query/@Mutation return types)
1570
+ * 3. Swagger @ApiOkResponse / @ApiCreatedResponse type (automatic for REST)
1571
+ * 4. No conversion (existing interceptors work as before)
1572
+ *
1573
+ * Results are cached per route handler for zero-cost subsequent lookups.
1574
+ * default = true (opt-out via false)
1575
+ *
1576
+ * @since 11.18.0
1577
+ */
1578
+ responseModelInterceptor?:
1579
+ | boolean
1580
+ | {
1581
+ /**
1582
+ * Log a warning when auto-conversion from plain object to model occurs.
1583
+ * Useful for identifying services that bypass CrudService.
1584
+ * default = false
1585
+ */
1586
+ debug?: boolean;
1587
+ };
1588
+
1589
+ /**
1590
+ * TranslateResponseInterceptor - automatically applies _translations
1591
+ * to response objects based on the Accept-Language header of the request.
1592
+ * Reads @Translatable field values from _translations and overwrites
1593
+ * the base-language field with the requested translation.
1594
+ * Includes early bailout: skips recursive traversal when no _translations
1595
+ * are present in the response.
1596
+ * Ensures translations work even when CrudService.process() is bypassed.
1597
+ * default = true (opt-out via false)
1598
+ *
1599
+ * @since 11.18.0
1600
+ */
1601
+ translateResponseInterceptor?: boolean;
1602
+
1603
+ /**
1604
+ * Global list of field names that should always be removed from responses.
1605
+ * This is a fallback safety net applied after all other security checks.
1606
+ * Takes precedence over checkSecurityInterceptor.secretFields if both are set.
1607
+ * default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
1608
+ *
1609
+ * @since 11.18.0
1610
+ */
1611
+ secretFields?: string[];
1452
1612
  };
1453
1613
 
1454
1614
  /**
@@ -0,0 +1,25 @@
1
+ import { Injectable, NestMiddleware } from '@nestjs/common';
2
+ import { NextFunction, Request, Response } from 'express';
3
+
4
+ import { IRequestContext, RequestContext } from '../services/request-context.service';
5
+
6
+ /**
7
+ * Middleware that wraps each request in a RequestContext (AsyncLocalStorage).
8
+ * Uses a lazy getter for currentUser so that the user is resolved at access time,
9
+ * not at middleware execution time. This ensures that auth middleware that runs
10
+ * after this middleware still sets req.user before it's read.
11
+ */
12
+ @Injectable()
13
+ export class RequestContextMiddleware implements NestMiddleware {
14
+ use(req: Request, _res: Response, next: NextFunction) {
15
+ const context: IRequestContext = {
16
+ get currentUser() {
17
+ return (req as any).user || undefined;
18
+ },
19
+ get language() {
20
+ return req.headers?.['accept-language'] || undefined;
21
+ },
22
+ };
23
+ RequestContext.run(context, () => next());
24
+ }
25
+ }
@@ -244,10 +244,10 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
244
244
  // Special handling for validators with arrays when 'each' option is set
245
245
  // The 'each' property indicates validation should be applied to each array element
246
246
  const isArrayValue = Array.isArray(propertyValue);
247
- const shouldValidateEach =
247
+ const shouldValidateEachItem =
248
248
  metadata.each === true || (metadata as any).validationOptions?.each === true;
249
249
 
250
- if (isArrayValue && shouldValidateEach) {
250
+ if (isArrayValue && shouldValidateEachItem) {
251
251
  // Validate each array element individually
252
252
  const results: boolean[] = [];
253
253
  for (const item of propertyValue) {
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@nestjs/apollo';
2
2
  import { GraphQLSchemaHost } from '@nestjs/graphql';
3
- import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo-server-plugin-base';
3
+ import type { ApolloServerPlugin, BaseContext, GraphQLRequestListener } from '@apollo/server';
4
4
  import { GraphQLError } from 'graphql';
5
5
  import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
6
6
 
@@ -13,7 +13,7 @@ export class ComplexityPlugin implements ApolloServerPlugin {
13
13
  private configService: ConfigService,
14
14
  ) {}
15
15
 
16
- async requestDidStart(): Promise<GraphQLRequestListener> {
16
+ async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
17
17
  const maxComplexity: number = this.configService.getFastButReadOnly('graphQl.maxComplexity');
18
18
  const { schema } = this.gqlSchemaHost;
19
19
 
@@ -0,0 +1,74 @@
1
+ import { RequestContext } from '../services/request-context.service';
2
+
3
+ /**
4
+ * Mongoose plugin that automatically sets createdBy/updatedBy fields.
5
+ * Uses RequestContext (AsyncLocalStorage) to access the current user.
6
+ *
7
+ * Behavior:
8
+ * - No user context (system operations, seeding): fields are not set
9
+ * - New documents: sets createdBy (if not already set) and updatedBy
10
+ * - Existing documents on save: sets updatedBy
11
+ * - Update operations: sets updatedBy
12
+ *
13
+ * Only activates on schemas that have createdBy and/or updatedBy fields defined.
14
+ */
15
+ export function mongooseAuditFieldsPlugin(schema) {
16
+ const hasCreatedBy = !!schema.path('createdBy');
17
+ const hasUpdatedBy = !!schema.path('updatedBy');
18
+
19
+ // Skip schemas without audit fields (e.g. BetterAuth sessions, third-party schemas)
20
+ if (!hasCreatedBy && !hasUpdatedBy) {
21
+ return;
22
+ }
23
+
24
+ // Pre-save hook
25
+ schema.pre('save', function () {
26
+ const currentUser = RequestContext.getCurrentUser();
27
+ if (!currentUser?.id) {
28
+ return;
29
+ }
30
+
31
+ if (hasCreatedBy && this.isNew && !this['createdBy']) {
32
+ this['createdBy'] = currentUser.id;
33
+ }
34
+ if (hasUpdatedBy) {
35
+ this['updatedBy'] = currentUser.id;
36
+ }
37
+ });
38
+
39
+ // Pre-update hooks (only needed for updatedBy)
40
+ if (hasUpdatedBy) {
41
+ const updateHook = function () {
42
+ const currentUser = RequestContext.getCurrentUser();
43
+ if (!currentUser?.id) {
44
+ return;
45
+ }
46
+
47
+ const update = this.getUpdate();
48
+ if (!update) {
49
+ return;
50
+ }
51
+
52
+ // Handle both flat updates and $set operator
53
+ update['updatedBy'] = currentUser.id;
54
+ if (update.$set) {
55
+ update.$set['updatedBy'] = currentUser.id;
56
+ }
57
+
58
+ // Handle upsert: set createdBy on insert via $setOnInsert
59
+ // Only add if createdBy is not already in the update (avoids MongoDB path conflict)
60
+ if (hasCreatedBy && !update['createdBy'] && !update.$set?.['createdBy']) {
61
+ if (!update.$setOnInsert) {
62
+ update.$setOnInsert = {};
63
+ }
64
+ if (!update.$setOnInsert['createdBy']) {
65
+ update.$setOnInsert['createdBy'] = currentUser.id;
66
+ }
67
+ }
68
+ };
69
+
70
+ schema.pre('findOneAndUpdate', updateHook);
71
+ schema.pre('updateOne', updateHook);
72
+ schema.pre('updateMany', updateHook);
73
+ }
74
+ }
@@ -0,0 +1,100 @@
1
+ import bcrypt = require('bcrypt');
2
+ import { sha256 } from 'js-sha256';
3
+
4
+ import { ConfigService } from '../services/config.service';
5
+
6
+ /**
7
+ * Mongoose plugin that automatically hashes passwords before saving to the database.
8
+ * Handles save(), findOneAndUpdate(), updateOne(), and updateMany() operations.
9
+ *
10
+ * Prevents plaintext passwords from being stored even when developers bypass
11
+ * CrudService.process() and use direct Mongoose operations.
12
+ *
13
+ * Detects already-hashed values (BCrypt pattern) to prevent double-hashing.
14
+ *
15
+ * For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
16
+ * skipPatterns in security.mongoosePasswordPlugin to preserve them as-is.
17
+ */
18
+ export function mongoosePasswordPlugin(schema) {
19
+ // Pre-save hook
20
+ schema.pre('save', async function () {
21
+ if (!this.isModified('password') || !this['password']) {
22
+ return;
23
+ }
24
+ this['password'] = await hashPassword(this['password']);
25
+ });
26
+
27
+ // Pre-findOneAndUpdate hook
28
+ schema.pre('findOneAndUpdate', async function () {
29
+ await hashUpdatePassword(this.getUpdate());
30
+ });
31
+
32
+ // Pre-updateOne hook
33
+ schema.pre('updateOne', async function () {
34
+ await hashUpdatePassword(this.getUpdate());
35
+ });
36
+
37
+ // Pre-updateMany hook
38
+ schema.pre('updateMany', async function () {
39
+ await hashUpdatePassword(this.getUpdate());
40
+ });
41
+ }
42
+
43
+ export async function hashUpdatePassword(update: any) {
44
+ if (!update) {
45
+ return;
46
+ }
47
+ if (update.password) {
48
+ update.password = await hashPassword(update.password);
49
+ }
50
+ if (update.$set?.password) {
51
+ update.$set.password = await hashPassword(update.$set.password);
52
+ }
53
+ }
54
+
55
+ // Compiled RegExp cache (built once on first call)
56
+ let compiledSkipPatterns: RegExp[] | null = null;
57
+
58
+ /**
59
+ * Reset the compiled skip patterns cache.
60
+ * Use in test environments where ConfigService is reset between test suites.
61
+ */
62
+ export function resetSkipPatternsCache(): void {
63
+ compiledSkipPatterns = null;
64
+ }
65
+
66
+ function getSkipPatterns(): RegExp[] {
67
+ if (compiledSkipPatterns !== null) {
68
+ return compiledSkipPatterns;
69
+ }
70
+ const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongoosePasswordPlugin;
71
+ if (pluginConfig && typeof pluginConfig === 'object' && 'skipPatterns' in pluginConfig && pluginConfig.skipPatterns) {
72
+ compiledSkipPatterns = (pluginConfig.skipPatterns as (string | RegExp)[]).map((p) =>
73
+ p instanceof RegExp ? p : new RegExp(p),
74
+ );
75
+ } else {
76
+ compiledSkipPatterns = [];
77
+ }
78
+ return compiledSkipPatterns;
79
+ }
80
+
81
+ export async function hashPassword(password: string): Promise<string> {
82
+ // Already BCrypt-hashed → skip
83
+ if (/^\$2[aby]\$\d+\$/.test(password)) {
84
+ return password;
85
+ }
86
+
87
+ // Check configured skip patterns (e.g. sentinel/lock values)
88
+ const skipPatterns = getSkipPatterns();
89
+ if (skipPatterns.some((pattern) => pattern.test(password))) {
90
+ return password;
91
+ }
92
+
93
+ // SHA256 pre-hash if configured and password is not already SHA256
94
+ const sha256Enabled = ConfigService.configFastButReadOnly?.sha256;
95
+ if (sha256Enabled && !/^[a-f0-9]{64}$/i.test(password)) {
96
+ password = sha256(password);
97
+ }
98
+
99
+ return bcrypt.hash(password, 10);
100
+ }
@@ -0,0 +1,150 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { RoleEnum } from '../enums/role.enum';
4
+ import { ConfigService } from '../services/config.service';
5
+ import { RequestContext } from '../services/request-context.service';
6
+
7
+ const logger = new Logger('mongooseRoleGuardPlugin');
8
+
9
+ /**
10
+ * Mongoose plugin that prevents unauthorized users from escalating roles.
11
+ * Uses RequestContext (AsyncLocalStorage) to access the current user.
12
+ *
13
+ * **When are role changes allowed?**
14
+ * 1. No user context (system operations, seeding, CLI scripts) → allowed
15
+ * 2. No currentUser on request (e.g. signUp — user not logged in) → allowed
16
+ * 3. User has ADMIN role → allowed
17
+ * 4. User has one of the configured `allowedRoles` → allowed
18
+ * 5. `RequestContext.runWithBypassRoleGuard()` is active → allowed
19
+ * 6. `CrudService.process()` with `force: true` → allowed (auto-bypasses)
20
+ *
21
+ * **When are role changes blocked?**
22
+ * - Logged-in non-admin user without bypass → blocked
23
+ * - On save (new): roles set to `[]`
24
+ * - On save (existing): roles reverted to original
25
+ * - On update: roles stripped from update object
26
+ *
27
+ * **Configuration** via `security.mongooseRoleGuardPlugin`:
28
+ * - `true` — Only ADMIN can assign roles (default)
29
+ * - `{ allowedRoles: ['ORGA', 'HR_MANAGER'] }` — Additional roles that can assign roles
30
+ * - `false` — Plugin disabled entirely
31
+ *
32
+ * **Bypass for authorized service code** (e.g. HR system creating users with roles):
33
+ * ```typescript
34
+ * import { RequestContext } from '@lenne.tech/nest-server';
35
+ *
36
+ * // Wrap the database operation in runWithBypassRoleGuard
37
+ * await RequestContext.runWithBypassRoleGuard(async () => {
38
+ * await this.mainDbModel.create({ email, roles: ['EMPLOYEE'] });
39
+ * });
40
+ *
41
+ * // Or use CrudService.process() with force: true
42
+ * return this.process(
43
+ * async () => this.mainDbModel.findByIdAndUpdate(id, { roles }),
44
+ * { serviceOptions, force: true },
45
+ * );
46
+ * ```
47
+ */
48
+ export function mongooseRoleGuardPlugin(schema) {
49
+ // Pre-save hook
50
+ schema.pre('save', function () {
51
+ if (!this.isModified('roles')) {
52
+ return;
53
+ }
54
+
55
+ if (isRoleChangeAllowed()) {
56
+ return;
57
+ }
58
+
59
+ // Unauthorized: prevent role escalation
60
+ const currentUser = RequestContext.getCurrentUser();
61
+ logger.debug(
62
+ `[nest-server] mongooseRoleGuardPlugin: Blocked role change on ${this.isNew ? 'new' : 'existing'} document by user ${currentUser?.id || 'unknown'}`,
63
+ );
64
+ if (this.isNew) {
65
+ this['roles'] = [];
66
+ } else {
67
+ // Revert to original value
68
+ this.unmarkModified('roles');
69
+ }
70
+ });
71
+
72
+ // Pre-findOneAndUpdate hook
73
+ schema.pre('findOneAndUpdate', function () {
74
+ handleUpdateRoleGuard(this.getUpdate());
75
+ });
76
+
77
+ // Pre-updateOne hook
78
+ schema.pre('updateOne', function () {
79
+ handleUpdateRoleGuard(this.getUpdate());
80
+ });
81
+
82
+ // Pre-updateMany hook
83
+ schema.pre('updateMany', function () {
84
+ handleUpdateRoleGuard(this.getUpdate());
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Check if the current user is allowed to modify roles.
90
+ * Returns true if:
91
+ * - No user context (system operation)
92
+ * - bypassRoleGuard is active (via RequestContext.runWithBypassRoleGuard())
93
+ * - User has ADMIN role
94
+ * - User has one of the configured allowedRoles
95
+ */
96
+ function isRoleChangeAllowed(): boolean {
97
+ const currentUser = RequestContext.getCurrentUser();
98
+ // No user context (system operation) → allow
99
+ if (!currentUser) {
100
+ return true;
101
+ }
102
+ // Explicit bypass (e.g. signUp with default roles, authorized service operations)
103
+ if (RequestContext.isBypassRoleGuard()) {
104
+ return true;
105
+ }
106
+ // Admin → always allow
107
+ if (currentUser.hasRole?.([RoleEnum.ADMIN]) || currentUser.roles?.includes(RoleEnum.ADMIN)) {
108
+ return true;
109
+ }
110
+
111
+ // Check configured allowedRoles
112
+ const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongooseRoleGuardPlugin;
113
+ if (pluginConfig && typeof pluginConfig === 'object' && 'allowedRoles' in pluginConfig && pluginConfig.allowedRoles) {
114
+ const allowedRoles: string[] = pluginConfig.allowedRoles as string[];
115
+ if (currentUser.roles?.some((role: string) => allowedRoles.includes(role))) {
116
+ return true;
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ function handleUpdateRoleGuard(update: any) {
124
+ if (!update) {
125
+ return;
126
+ }
127
+
128
+ const hasRolesUpdate = update.roles || update.$set?.roles || update.$push?.roles || update.$addToSet?.roles;
129
+ if (!hasRolesUpdate) {
130
+ return;
131
+ }
132
+
133
+ if (isRoleChangeAllowed()) {
134
+ return;
135
+ }
136
+
137
+ // Unauthorized: remove roles changes with debug log
138
+ const currentUser = RequestContext.getCurrentUser();
139
+ logger.debug(`Stripped unauthorized roles change from user ${currentUser?.id || 'unknown'}`);
140
+ delete update.roles;
141
+ if (update.$set?.roles) {
142
+ delete update.$set.roles;
143
+ }
144
+ if (update.$push?.roles) {
145
+ delete update.$push.roles;
146
+ }
147
+ if (update.$addToSet?.roles) {
148
+ delete update.$addToSet.roles;
149
+ }
150
+ }
@@ -74,8 +74,8 @@ export class ConfigService {
74
74
 
75
75
  // Init subject handling
76
76
  if (!isInitialized) {
77
- ConfigService._configSubject$.subscribe((config) => {
78
- ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(config)));
77
+ ConfigService._configSubject$.subscribe((value) => {
78
+ ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(value)));
79
79
  });
80
80
  }
81
81
 
@@ -0,0 +1,25 @@
1
+ import { CoreModel } from '../models/core-model.model';
2
+
3
+ /**
4
+ * Central registry mapping Mongoose model names to CoreModel classes.
5
+ * Populated automatically by ModuleService constructors.
6
+ */
7
+ export class ModelRegistry {
8
+ private static models = new Map<string, new (...args: any[]) => CoreModel>();
9
+
10
+ static register(dbModelName: string, modelClass: new (...args: any[]) => CoreModel): void {
11
+ this.models.set(dbModelName, modelClass);
12
+ }
13
+
14
+ static getModelClass(dbModelName: string): (new (...args: any[]) => CoreModel) | undefined {
15
+ return this.models.get(dbModelName);
16
+ }
17
+
18
+ static getAll(): Map<string, new (...args: any[]) => CoreModel> {
19
+ return this.models;
20
+ }
21
+
22
+ static clear(): void {
23
+ this.models.clear();
24
+ }
25
+ }