@lenne.tech/nest-server 11.19.0 → 11.20.1

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 (42) hide show
  1. package/dist/core/common/helpers/db.helper.d.ts +1 -1
  2. package/dist/core/common/helpers/db.helper.js +10 -4
  3. package/dist/core/common/helpers/db.helper.js.map +1 -1
  4. package/dist/core/common/helpers/input.helper.d.ts +1 -1
  5. package/dist/core/common/helpers/input.helper.js +6 -2
  6. package/dist/core/common/helpers/input.helper.js.map +1 -1
  7. package/dist/core/common/interceptors/check-security.interceptor.js +8 -0
  8. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
  10. package/dist/core/common/middleware/request-context.middleware.js +8 -0
  11. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  12. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +72 -0
  13. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -1
  14. package/dist/core/common/plugins/mongoose-password.plugin.js +35 -0
  15. package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -1
  16. package/dist/core/common/plugins/mongoose-role-guard.plugin.js +61 -0
  17. package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -1
  18. package/dist/core/common/plugins/mongoose-tenant.plugin.d.ts +1 -0
  19. package/dist/core/common/plugins/mongoose-tenant.plugin.js +108 -0
  20. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -0
  21. package/dist/core/common/services/request-context.service.d.ts +5 -0
  22. package/dist/core/common/services/request-context.service.js +14 -0
  23. package/dist/core/common/services/request-context.service.js.map +1 -1
  24. package/dist/core.module.js +4 -0
  25. package/dist/core.module.js.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/tsconfig.build.tsbuildinfo +1 -1
  30. package/package.json +11 -12
  31. package/src/core/common/helpers/db.helper.ts +13 -6
  32. package/src/core/common/helpers/input.helper.ts +6 -2
  33. package/src/core/common/interceptors/check-security.interceptor.ts +8 -0
  34. package/src/core/common/interfaces/server-options.interface.ts +80 -0
  35. package/src/core/common/middleware/request-context.middleware.ts +8 -1
  36. package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +84 -0
  37. package/src/core/common/plugins/mongoose-password.plugin.ts +41 -1
  38. package/src/core/common/plugins/mongoose-role-guard.plugin.ts +65 -1
  39. package/src/core/common/plugins/mongoose-tenant.plugin.ts +165 -0
  40. package/src/core/common/services/request-context.service.ts +37 -0
  41. package/src/core.module.ts +5 -0
  42. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.19.0",
3
+ "version": "11.20.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -91,11 +91,10 @@
91
91
  "@nestjs/websockets": "11.1.16",
92
92
  "@tus/file-store": "2.0.0",
93
93
  "@tus/server": "2.3.0",
94
-
95
94
  "bcrypt": "6.0.0",
96
95
  "better-auth": "1.5.4",
97
96
  "class-transformer": "0.5.1",
98
- "class-validator": "0.14.3",
97
+ "class-validator": "0.15.1",
99
98
  "compression": "1.8.1",
100
99
  "cookie-parser": "1.4.7",
101
100
  "dotenv": "17.3.1",
@@ -108,16 +107,17 @@
108
107
  "js-sha256": "0.11.1",
109
108
  "json-to-graphql-query": "2.3.0",
110
109
  "lodash": "4.17.23",
111
- "mongodb": "7.0.0",
112
- "mongoose": "9.2.4",
110
+ "mongodb": "7.1.0",
111
+ "mongoose": "9.3.0",
113
112
  "multer": "2.1.1",
114
113
  "node-mailjet": "6.0.11",
115
- "nodemailer": "8.0.1",
114
+ "nodemailer": "8.0.2",
116
115
  "passport": "0.7.0",
117
116
  "passport-jwt": "4.0.1",
118
117
  "reflect-metadata": "0.2.2",
119
118
  "rfdc": "1.4.1",
120
119
  "rxjs": "7.8.2",
120
+ "ts-morph": "27.0.2",
121
121
  "yuml-diagram": "1.2.0"
122
122
  },
123
123
  "devDependencies": {
@@ -133,7 +133,7 @@
133
133
  "@types/express": "5.0.6",
134
134
  "@types/lodash": "4.17.24",
135
135
  "@types/multer": "2.1.0",
136
- "@types/node": "25.3.5",
136
+ "@types/node": "25.4.0",
137
137
  "@types/nodemailer": "7.0.11",
138
138
  "@types/passport": "1.0.17",
139
139
  "@types/supertest": "7.2.0",
@@ -145,11 +145,10 @@
145
145
  "nodemon": "3.1.14",
146
146
  "npm-watch": "0.13.0",
147
147
  "otpauth": "9.5.0",
148
- "oxfmt": "0.36.0",
149
- "oxlint": "1.51.0",
148
+ "oxfmt": "0.38.0",
149
+ "oxlint": "1.53.0",
150
150
  "rimraf": "6.1.3",
151
151
  "supertest": "7.2.2",
152
- "ts-morph": "27.0.2",
153
152
  "ts-node": "10.9.2",
154
153
  "tsconfig-paths": "4.2.0",
155
154
  "tus-js-client": "4.3.1",
@@ -181,9 +180,9 @@
181
180
  "minimatch@>=9.0.0 <9.0.7": "9.0.7",
182
181
  "minimatch@>=10.0.0 <10.2.3": "10.2.4",
183
182
  "rollup@>=4.0.0 <4.59.0": "4.59.0",
184
- "serialize-javascript@<=7.0.2": "7.0.4",
185
183
  "ajv@<6.14.0": "6.14.0",
186
- "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0"
184
+ "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
185
+ "file-type@>=13.0.0 <21.3.1": "21.3.1"
187
186
  },
188
187
  "onlyBuiltDependencies": [
189
188
  "bcrypt",
@@ -550,6 +550,7 @@ export function removeUnresolvedReferences<T = any>(
550
550
  populated: T,
551
551
  populatedOptions: (PopulateOptions | string)[] | PopulateOptions | PopulateOptions[] | string,
552
552
  ignoreFirst = true,
553
+ visited: WeakSet<object> = new WeakSet(),
553
554
  ): T {
554
555
  // Check parameter
555
556
  if (!populated || !populatedOptions) {
@@ -558,21 +559,24 @@ export function removeUnresolvedReferences<T = any>(
558
559
 
559
560
  // Process array
560
561
  if (Array.isArray(populated)) {
561
- populated.forEach((p) => removeUnresolvedReferences(p, populatedOptions, false));
562
+ if (visited.has(populated)) return populated;
563
+ visited.add(populated);
564
+ populated.forEach((p) => removeUnresolvedReferences(p, populatedOptions, false, visited));
562
565
  return populated;
563
566
  }
564
567
 
565
568
  // Process object
566
569
  if (typeof populated === 'object') {
567
- // populatedOptions is an array
570
+ // populatedOptions is an array — iterate options for the same object
571
+ // Each option targets a different property path, so do not mark populated as visited here
568
572
  if (Array.isArray(populatedOptions)) {
569
573
  populatedOptions.forEach((po) =>
570
- removeUnresolvedReferences(populated, ignoreFirst && typeof po === 'object' ? po.populate : po, false),
574
+ removeUnresolvedReferences(populated, ignoreFirst && typeof po === 'object' ? po.populate : po, false, visited),
571
575
  );
572
576
  return populated;
573
577
  }
574
578
 
575
- // populatedOptions is a string
579
+ // populatedOptions is a string — leaf operation, no deep recursion risk
576
580
  if (typeof populatedOptions === 'string') {
577
581
  if (!['_id', 'id'].includes(populatedOptions) && populated[populatedOptions] instanceof Types.ObjectId) {
578
582
  populated[populatedOptions] = null;
@@ -580,13 +584,16 @@ export function removeUnresolvedReferences<T = any>(
580
584
  return populated;
581
585
  }
582
586
 
583
- // populatedOptions is an PopulateOptions object
587
+ // populatedOptions is a PopulateOptions object
584
588
  if (populatedOptions.path) {
585
589
  const key = populatedOptions.path;
586
590
  if (!['_id', 'id'].includes(key) && populated[key] instanceof Types.ObjectId) {
587
591
  populated[key] = null;
588
592
  } else if (populatedOptions.populate) {
589
- removeUnresolvedReferences(populated[key], populatedOptions.populate, false);
593
+ // Prevent circular reference loops when descending into nested populates
594
+ if (visited.has(populated as object)) return populated;
595
+ visited.add(populated as object);
596
+ removeUnresolvedReferences(populated[key], populatedOptions.populate, false, visited);
590
597
  }
591
598
  }
592
599
  }
@@ -413,12 +413,16 @@ export function combinePlain(...args: Record<any, any>[]): any {
413
413
  /**
414
414
  * Get deep frozen object
415
415
  */
416
- export function deepFreeze(object: any) {
416
+ export function deepFreeze(object: any, visited: WeakSet<object> = new WeakSet()) {
417
417
  if (!object || typeof object !== 'object') {
418
418
  return object;
419
419
  }
420
+ if (visited.has(object)) {
421
+ return object;
422
+ }
423
+ visited.add(object);
420
424
  for (const [key, value] of Object.entries(object)) {
421
- object[key] = deepFreeze(value);
425
+ object[key] = deepFreeze(value, visited);
422
426
  }
423
427
  return Object.freeze(object);
424
428
  }
@@ -116,17 +116,25 @@ export class CheckSecurityInterceptor implements NestInterceptor {
116
116
  const proto = Object.getPrototypeOf(val);
117
117
  return proto === null || proto === Object.prototype || typeof val.constructor === 'function';
118
118
  };
119
+ const visited = new WeakSet();
119
120
  const removeSecrets = (data: any) => {
120
121
  if (!this.config.removeSecretFields || !data || typeof data !== 'object') {
121
122
  return data;
122
123
  }
123
124
  if (Array.isArray(data)) {
125
+ if (visited.has(data)) return data;
126
+ visited.add(data);
124
127
  data.forEach(removeSecrets);
125
128
  return data;
126
129
  }
127
130
  if (!isPlainLike(data)) {
128
131
  return data;
129
132
  }
133
+ // Prevent infinite recursion on circular references
134
+ if (visited.has(data)) {
135
+ return data;
136
+ }
137
+ visited.add(data);
130
138
  for (const field of this.config.secretFields) {
131
139
  if (field in data && data[field] !== undefined) {
132
140
  data[field] = undefined;
@@ -816,6 +816,50 @@ export interface IJwt {
816
816
  signInOptions?: JwtSignOptions;
817
817
  }
818
818
 
819
+ /**
820
+ * Multi-tenancy configuration
821
+ *
822
+ * Follows the "presence implies enabled" pattern:
823
+ * - `undefined`: Feature disabled (no overhead)
824
+ * - `{}`: Feature enabled with defaults
825
+ * - `{ enabled: false }`: Pre-configured but disabled
826
+ */
827
+ /**
828
+ * Multi-tenancy configuration for automatic tenant-based data isolation.
829
+ *
830
+ * @since 11.20.0
831
+ */
832
+ export interface IMultiTenancy {
833
+ /**
834
+ * Explicitly disable multi-tenancy even when config is present.
835
+ * When undefined/true and the config object exists, the feature is enabled.
836
+ * @default true (when config object is present)
837
+ */
838
+ enabled?: boolean;
839
+
840
+ /**
841
+ * Field name on `req.user` that contains the tenant identifier.
842
+ *
843
+ * **Important:** This only controls which property on `req.user` is read.
844
+ * The document schema field name is always `tenantId` and is not configurable.
845
+ * Example: `userField: 'organizationId'` reads `req.user.organizationId` but
846
+ * filters/sets the `tenantId` field on documents.
847
+ *
848
+ * **Falsy values:** If the resolved value is falsy (undefined, null, or empty string ''),
849
+ * the user is treated as having no tenant — they will only see documents with `tenantId: null`.
850
+ *
851
+ * @default 'tenantId'
852
+ */
853
+ userField?: string;
854
+
855
+ /**
856
+ * Model names (NOT collection names) to exclude from tenant filtering.
857
+ * These schemas will not have tenant isolation applied.
858
+ * @example ['User', 'Session']
859
+ */
860
+ excludeSchemas?: string[];
861
+ }
862
+
819
863
  /**
820
864
  * Options for the server
821
865
  */
@@ -1318,6 +1362,42 @@ export interface IServerOptions {
1318
1362
  uri: string;
1319
1363
  };
1320
1364
 
1365
+ /**
1366
+ * Multi-tenancy configuration for tenant-based data isolation.
1367
+ *
1368
+ * When enabled, a global Mongoose plugin automatically filters all queries
1369
+ * by `tenantId`, ensuring tenant isolation in a shared database.
1370
+ *
1371
+ * Follows the "presence implies enabled" pattern:
1372
+ * - `undefined`: Disabled (no overhead, backward compatible)
1373
+ * - `{}`: Enabled with defaults
1374
+ * - `{ userField: 'organizationId' }`: Enabled with custom user field
1375
+ * - `{ enabled: false }`: Pre-configured but disabled
1376
+ *
1377
+ * The plugin activates automatically on any schema that has a `tenantId` field.
1378
+ * Schemas without `tenantId` are not affected.
1379
+ *
1380
+ * @since 11.20.0
1381
+ * @default undefined (disabled)
1382
+ *
1383
+ * @example
1384
+ * ```typescript
1385
+ * // Enable with defaults
1386
+ * multiTenancy: {},
1387
+ *
1388
+ * // Enable with excluded schemas
1389
+ * multiTenancy: {
1390
+ * excludeSchemas: ['User', 'Session'],
1391
+ * },
1392
+ *
1393
+ * // Custom user field
1394
+ * multiTenancy: {
1395
+ * userField: 'organizationId',
1396
+ * },
1397
+ * ```
1398
+ */
1399
+ multiTenancy?: IMultiTenancy;
1400
+
1321
1401
  /**
1322
1402
  * Permissions report module (development tool).
1323
1403
  *
@@ -1,11 +1,12 @@
1
1
  import { Injectable, NestMiddleware } from '@nestjs/common';
2
2
  import { NextFunction, Request, Response } from 'express';
3
3
 
4
+ import { ConfigService } from '../services/config.service';
4
5
  import { IRequestContext, RequestContext } from '../services/request-context.service';
5
6
 
6
7
  /**
7
8
  * 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
+ * Uses lazy getters for currentUser and tenantId so that they are resolved at access time,
9
10
  * not at middleware execution time. This ensures that auth middleware that runs
10
11
  * after this middleware still sets req.user before it's read.
11
12
  */
@@ -19,6 +20,12 @@ export class RequestContextMiddleware implements NestMiddleware {
19
20
  get language() {
20
21
  return req.headers?.['accept-language'] || undefined;
21
22
  },
23
+ get tenantId() {
24
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
25
+ if (!config || config.enabled === false) return undefined;
26
+ const field = config.userField ?? 'tenantId';
27
+ return (req as any).user?.[field] ?? undefined;
28
+ },
22
29
  };
23
30
  RequestContext.run(context, () => next());
24
31
  }
@@ -4,11 +4,16 @@ import { RequestContext } from '../services/request-context.service';
4
4
  * Mongoose plugin that automatically sets createdBy/updatedBy fields.
5
5
  * Uses RequestContext (AsyncLocalStorage) to access the current user.
6
6
  *
7
+ * Handles save(), findOneAndUpdate(), updateOne(), updateMany(), replaceOne(),
8
+ * findOneAndReplace(), insertMany(), and bulkWrite() operations.
9
+ *
7
10
  * Behavior:
8
11
  * - No user context (system operations, seeding): fields are not set
9
12
  * - New documents: sets createdBy (if not already set) and updatedBy
10
13
  * - Existing documents on save: sets updatedBy
11
14
  * - Update operations: sets updatedBy
15
+ * - Replace operations: sets updatedBy (and createdBy for upserts)
16
+ * - Bulk operations: sets createdBy/updatedBy per document/operation
12
17
  *
13
18
  * Only activates on schemas that have createdBy and/or updatedBy fields defined.
14
19
  */
@@ -70,5 +75,84 @@ export function mongooseAuditFieldsPlugin(schema) {
70
75
  schema.pre('findOneAndUpdate', updateHook);
71
76
  schema.pre('updateOne', updateHook);
72
77
  schema.pre('updateMany', updateHook);
78
+
79
+ // Replace hooks: flat replacement doc (no $set/$setOnInsert)
80
+ const replaceHook = function () {
81
+ const currentUser = RequestContext.getCurrentUser();
82
+ if (!currentUser?.id) {
83
+ return;
84
+ }
85
+
86
+ const replacement = this.getUpdate();
87
+ if (!replacement) {
88
+ return;
89
+ }
90
+
91
+ replacement['updatedBy'] = currentUser.id;
92
+
93
+ // For upsert replacements: set createdBy if not already present
94
+ if (hasCreatedBy && !replacement['createdBy']) {
95
+ replacement['createdBy'] = currentUser.id;
96
+ }
97
+ };
98
+
99
+ schema.pre('replaceOne', replaceHook);
100
+ schema.pre('findOneAndReplace', replaceHook);
73
101
  }
102
+
103
+ // Bulk operation hooks (needed when either createdBy or updatedBy is present)
104
+ // Pre-insertMany hook (Mongoose 9: first arg is docs array)
105
+ schema.pre('insertMany', function (docs) {
106
+ const currentUser = RequestContext.getCurrentUser();
107
+ if (!currentUser?.id) return;
108
+ if (!Array.isArray(docs)) return;
109
+
110
+ for (const doc of docs) {
111
+ if (hasCreatedBy && !doc.createdBy) {
112
+ doc.createdBy = currentUser.id;
113
+ }
114
+ if (hasUpdatedBy) {
115
+ doc.updatedBy = currentUser.id;
116
+ }
117
+ }
118
+ });
119
+
120
+ // Pre-bulkWrite hook
121
+ schema.pre('bulkWrite', function (ops) {
122
+ const currentUser = RequestContext.getCurrentUser();
123
+ if (!currentUser?.id) return;
124
+
125
+ for (const op of ops) {
126
+ if ('insertOne' in op) {
127
+ if (hasCreatedBy && !op.insertOne.document.createdBy) {
128
+ op.insertOne.document.createdBy = currentUser.id;
129
+ }
130
+ if (hasUpdatedBy) {
131
+ op.insertOne.document.updatedBy = currentUser.id;
132
+ }
133
+ } else if ('updateOne' in op || 'updateMany' in op) {
134
+ const update = 'updateOne' in op ? op.updateOne.update : op.updateMany.update;
135
+ if (!update) continue;
136
+ if (hasUpdatedBy) {
137
+ update['updatedBy'] = currentUser.id;
138
+ if (update.$set) {
139
+ update.$set['updatedBy'] = currentUser.id;
140
+ }
141
+ }
142
+ if (hasCreatedBy && !update['createdBy'] && !update.$set?.['createdBy']) {
143
+ if (!update.$setOnInsert) update.$setOnInsert = {};
144
+ if (!update.$setOnInsert['createdBy']) {
145
+ update.$setOnInsert['createdBy'] = currentUser.id;
146
+ }
147
+ }
148
+ } else if ('replaceOne' in op) {
149
+ if (hasUpdatedBy) {
150
+ op.replaceOne.replacement['updatedBy'] = currentUser.id;
151
+ }
152
+ if (hasCreatedBy && !op.replaceOne.replacement['createdBy']) {
153
+ op.replaceOne.replacement['createdBy'] = currentUser.id;
154
+ }
155
+ }
156
+ }
157
+ });
74
158
  }
@@ -5,7 +5,8 @@ import { ConfigService } from '../services/config.service';
5
5
 
6
6
  /**
7
7
  * Mongoose plugin that automatically hashes passwords before saving to the database.
8
- * Handles save(), findOneAndUpdate(), updateOne(), and updateMany() operations.
8
+ * Handles save(), findOneAndUpdate(), updateOne(), updateMany(), replaceOne(),
9
+ * findOneAndReplace(), insertMany(), and bulkWrite() operations.
9
10
  *
10
11
  * Prevents plaintext passwords from being stored even when developers bypass
11
12
  * CrudService.process() and use direct Mongoose operations.
@@ -38,6 +39,45 @@ export function mongoosePasswordPlugin(schema) {
38
39
  schema.pre('updateMany', async function () {
39
40
  await hashUpdatePassword(this.getUpdate());
40
41
  });
42
+
43
+ // Pre-replaceOne hook (replacement doc is a flat object, hashUpdatePassword handles it)
44
+ schema.pre('replaceOne', async function () {
45
+ await hashUpdatePassword(this.getUpdate());
46
+ });
47
+
48
+ // Pre-findOneAndReplace hook
49
+ schema.pre('findOneAndReplace', async function () {
50
+ await hashUpdatePassword(this.getUpdate());
51
+ });
52
+
53
+ // Pre-insertMany hook (Mongoose 9: first arg is docs array)
54
+ schema.pre('insertMany', async function (docs) {
55
+ if (!Array.isArray(docs)) return;
56
+ for (const doc of docs) {
57
+ if (doc.password) {
58
+ doc.password = await hashPassword(doc.password);
59
+ }
60
+ }
61
+ });
62
+
63
+ // Pre-bulkWrite hook
64
+ schema.pre('bulkWrite', async function (ops) {
65
+ for (const op of ops) {
66
+ if ('insertOne' in op) {
67
+ if (op.insertOne.document?.password) {
68
+ op.insertOne.document.password = await hashPassword(op.insertOne.document.password);
69
+ }
70
+ } else if ('updateOne' in op) {
71
+ await hashUpdatePassword(op.updateOne.update);
72
+ } else if ('updateMany' in op) {
73
+ await hashUpdatePassword(op.updateMany.update);
74
+ } else if ('replaceOne' in op) {
75
+ if (op.replaceOne.replacement?.password) {
76
+ op.replaceOne.replacement.password = await hashPassword(op.replaceOne.replacement.password);
77
+ }
78
+ }
79
+ }
80
+ });
41
81
  }
42
82
 
43
83
  export async function hashUpdatePassword(update: any) {
@@ -10,6 +10,9 @@ const logger = new Logger('mongooseRoleGuardPlugin');
10
10
  * Mongoose plugin that prevents unauthorized users from escalating roles.
11
11
  * Uses RequestContext (AsyncLocalStorage) to access the current user.
12
12
  *
13
+ * Handles save(), findOneAndUpdate(), updateOne(), updateMany(), replaceOne(),
14
+ * findOneAndReplace(), insertMany(), and bulkWrite() operations.
15
+ *
13
16
  * **When are role changes allowed?**
14
17
  * 1. No user context (system operations, seeding, CLI scripts) → allowed
15
18
  * 2. No currentUser on request (e.g. signUp — user not logged in) → allowed
@@ -22,7 +25,8 @@ const logger = new Logger('mongooseRoleGuardPlugin');
22
25
  * - Logged-in non-admin user without bypass → blocked
23
26
  * - On save (new): roles set to `[]`
24
27
  * - On save (existing): roles reverted to original
25
- * - On update: roles stripped from update object
28
+ * - On update/replace: roles stripped from update/replacement object
29
+ * - On insertMany/bulkWrite: roles set to `[]` on inserted documents
26
30
  *
27
31
  * **Configuration** via `security.mongooseRoleGuardPlugin`:
28
32
  * - `true` — Only ADMIN can assign roles (default)
@@ -83,6 +87,62 @@ export function mongooseRoleGuardPlugin(schema) {
83
87
  schema.pre('updateMany', function () {
84
88
  handleUpdateRoleGuard(this.getUpdate());
85
89
  });
90
+
91
+ // Pre-replaceOne hook (replacement doc is flat, handleUpdateRoleGuard handles update.roles)
92
+ schema.pre('replaceOne', function () {
93
+ handleUpdateRoleGuard(this.getUpdate());
94
+ });
95
+
96
+ // Pre-findOneAndReplace hook
97
+ schema.pre('findOneAndReplace', function () {
98
+ handleUpdateRoleGuard(this.getUpdate());
99
+ });
100
+
101
+ // Pre-insertMany hook (Mongoose 9: first arg is docs array)
102
+ schema.pre('insertMany', function (docs) {
103
+ if (!Array.isArray(docs)) return;
104
+ if (!docs.some((doc) => doc.roles?.length > 0)) return;
105
+ if (isRoleChangeAllowed()) return;
106
+
107
+ const currentUser = RequestContext.getCurrentUser();
108
+ logger.debug(`Stripped unauthorized roles from insertMany by user ${currentUser?.id || 'unknown'}`);
109
+ for (const doc of docs) {
110
+ if (doc.roles) {
111
+ doc.roles = [];
112
+ }
113
+ }
114
+ });
115
+
116
+ // Pre-bulkWrite hook
117
+ schema.pre('bulkWrite', function (ops) {
118
+ const hasAnyRoleChange = ops.some((op) => {
119
+ if ('insertOne' in op) return !!op.insertOne.document?.roles?.length;
120
+ if ('updateOne' in op) return hasRolesInUpdate(op.updateOne.update);
121
+ if ('updateMany' in op) return hasRolesInUpdate(op.updateMany.update);
122
+ if ('replaceOne' in op) return !!op.replaceOne.replacement?.roles?.length;
123
+ return false;
124
+ });
125
+ if (!hasAnyRoleChange) return;
126
+ if (isRoleChangeAllowed()) return;
127
+
128
+ const currentUser = RequestContext.getCurrentUser();
129
+ logger.debug(`Stripped unauthorized roles from bulkWrite by user ${currentUser?.id || 'unknown'}`);
130
+ for (const op of ops) {
131
+ if ('insertOne' in op) {
132
+ if (op.insertOne.document?.roles) {
133
+ op.insertOne.document.roles = [];
134
+ }
135
+ } else if ('updateOne' in op) {
136
+ handleUpdateRoleGuard(op.updateOne.update);
137
+ } else if ('updateMany' in op) {
138
+ handleUpdateRoleGuard(op.updateMany.update);
139
+ } else if ('replaceOne' in op) {
140
+ if (op.replaceOne.replacement?.roles) {
141
+ delete op.replaceOne.replacement.roles;
142
+ }
143
+ }
144
+ }
145
+ });
86
146
  }
87
147
 
88
148
  /**
@@ -120,6 +180,10 @@ function isRoleChangeAllowed(): boolean {
120
180
  return false;
121
181
  }
122
182
 
183
+ function hasRolesInUpdate(update: any): boolean {
184
+ return !!(update?.roles || update?.$set?.roles || update?.$push?.roles || update?.$addToSet?.roles);
185
+ }
186
+
123
187
  function handleUpdateRoleGuard(update: any) {
124
188
  if (!update) {
125
189
  return;