@lenne.tech/nest-server 11.18.0 → 11.20.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 (48) hide show
  1. package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
  2. package/dist/core/common/middleware/request-context.middleware.js +8 -0
  3. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  4. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +72 -0
  5. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -1
  6. package/dist/core/common/plugins/mongoose-password.plugin.js +35 -0
  7. package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -1
  8. package/dist/core/common/plugins/mongoose-role-guard.plugin.js +61 -0
  9. package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -1
  10. package/dist/core/common/plugins/mongoose-tenant.plugin.d.ts +1 -0
  11. package/dist/core/common/plugins/mongoose-tenant.plugin.js +108 -0
  12. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -0
  13. package/dist/core/common/services/request-context.service.d.ts +5 -0
  14. package/dist/core/common/services/request-context.service.js +14 -0
  15. package/dist/core/common/services/request-context.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth.resolver.js +3 -1
  17. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  18. package/dist/core/modules/error-code/core-error-code.controller.js +1 -1
  19. package/dist/core/modules/error-code/core-error-code.controller.js.map +1 -1
  20. package/dist/core/modules/system-setup/core-system-setup.controller.js +1 -1
  21. package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
  22. package/dist/core.module.js +4 -0
  23. package/dist/core.module.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/server/modules/error-code/error-code.controller.js +1 -1
  28. package/dist/server/modules/error-code/error-code.controller.js.map +1 -1
  29. package/dist/tsconfig.build.tsbuildinfo +1 -1
  30. package/package.json +11 -12
  31. package/src/core/common/interfaces/server-options.interface.ts +82 -2
  32. package/src/core/common/middleware/request-context.middleware.ts +8 -1
  33. package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +84 -0
  34. package/src/core/common/plugins/mongoose-password.plugin.ts +41 -1
  35. package/src/core/common/plugins/mongoose-role-guard.plugin.ts +65 -1
  36. package/src/core/common/plugins/mongoose-tenant.plugin.ts +165 -0
  37. package/src/core/common/services/request-context.service.ts +37 -0
  38. package/src/core/modules/better-auth/core-better-auth.resolver.ts +3 -1
  39. package/src/core/modules/error-code/INTEGRATION-CHECKLIST.md +8 -8
  40. package/src/core/modules/error-code/core-error-code.controller.ts +3 -3
  41. package/src/core/modules/error-code/interfaces/error-code.interfaces.ts +1 -1
  42. package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +5 -5
  43. package/src/core/modules/system-setup/README.md +9 -9
  44. package/src/core/modules/system-setup/core-system-setup.controller.ts +1 -1
  45. package/src/core.module.ts +5 -0
  46. package/src/index.ts +1 -0
  47. package/src/server/modules/error-code/README.md +5 -5
  48. package/src/server/modules/error-code/error-code.controller.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.18.0",
3
+ "version": "11.20.0",
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",
@@ -53,7 +53,7 @@ export interface IAuth {
53
53
  *
54
54
  * Legacy endpoints include:
55
55
  * - GraphQL: signIn, signUp, signOut, refreshToken mutations
56
- * - REST: /api/auth/* endpoints
56
+ * - REST: /auth/* endpoints
57
57
  *
58
58
  * These can be disabled once all users have migrated to BetterAuth (IAM).
59
59
  *
@@ -156,7 +156,7 @@ export interface IAuthLegacyEndpoints {
156
156
 
157
157
  /**
158
158
  * Whether legacy REST auth endpoints are enabled.
159
- * Affects: /api/auth/sign-in, /api/auth/sign-up, etc.
159
+ * Affects: /auth/sign-in, /auth/sign-up, etc.
160
160
  *
161
161
  * @default true (inherits from `enabled`)
162
162
  */
@@ -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;
@@ -0,0 +1,165 @@
1
+ import { ConfigService } from '../services/config.service';
2
+ import { RequestContext } from '../services/request-context.service';
3
+
4
+ /**
5
+ * Mongoose plugin that provides automatic tenant-based data isolation.
6
+ * Only activates on schemas that have a `tenantId` path defined.
7
+ *
8
+ * Follows the same pattern as mongooseRoleGuardPlugin and mongooseAuditFieldsPlugin:
9
+ * - Plain function, registered globally in connectionFactory
10
+ * - Reads RequestContext (AsyncLocalStorage) and ConfigService.configFastButReadOnly
11
+ * - Activates conditionally based on schema structure
12
+ *
13
+ * **Behavior:**
14
+ * - Queries are automatically filtered by the current user's tenantId
15
+ * - New documents get tenantId set automatically from context
16
+ * - Aggregates get a $match stage prepended
17
+ *
18
+ * **No filter applied when:**
19
+ * - No RequestContext (system operations, cron jobs, migrations)
20
+ * - `bypassTenantGuard` is active (via `RequestContext.runWithBypassTenantGuard()`)
21
+ * - Schema's model name is in `excludeSchemas` config
22
+ * - No user on request (public endpoints)
23
+ *
24
+ * **User without tenantId:**
25
+ * - Filters by `{ tenantId: null }` — sees only data without tenant assignment
26
+ * - Falsy values (undefined, null, empty string '') are all treated as "no tenant"
27
+ */
28
+ export function mongooseTenantPlugin(schema) {
29
+ // Only activate on schemas with a tenantId path
30
+ if (!schema.path('tenantId')) {
31
+ return;
32
+ }
33
+
34
+ // Performance index
35
+ schema.index({ tenantId: 1 });
36
+
37
+ // === Query filter hooks (explicit names, no regex → no double-filtering) ===
38
+ const queryHooks = [
39
+ 'find',
40
+ 'findOne',
41
+ 'findOneAndUpdate',
42
+ 'findOneAndDelete',
43
+ 'findOneAndReplace',
44
+ 'countDocuments',
45
+ 'distinct',
46
+ 'updateOne',
47
+ 'updateMany',
48
+ 'deleteOne',
49
+ 'deleteMany',
50
+ 'replaceOne',
51
+ ];
52
+
53
+ for (const hookName of queryHooks) {
54
+ schema.pre(hookName, function () {
55
+ // Query hooks: `this` is a Mongoose Query — modelName is on `this.model`
56
+ const modelName = this.model?.modelName;
57
+ const tenantId = resolveTenantId(modelName);
58
+ if (tenantId !== undefined) {
59
+ this.where({ tenantId });
60
+ }
61
+ });
62
+ }
63
+
64
+ // === Save: set tenantId automatically on new documents ===
65
+ // Intentional asymmetry: writes only set tenantId when truthy (not null).
66
+ // A user without tenantId creates "unassigned" documents, which the null-filter
67
+ // in query hooks will still make visible to them on reads.
68
+ schema.pre('save', function () {
69
+ if (this.isNew && !this['tenantId']) {
70
+ // Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
71
+ const modelName = (this.constructor as any).modelName;
72
+ const tenantId = resolveTenantId(modelName);
73
+ if (tenantId) {
74
+ this['tenantId'] = tenantId;
75
+ }
76
+ }
77
+ });
78
+
79
+ // === insertMany (Mongoose 9: first arg is docs array, no next callback) ===
80
+ schema.pre('insertMany', function (docs: any[]) {
81
+ // Model-level hooks: `this` is the Model class itself — modelName is a direct property
82
+ const modelName = this.modelName;
83
+ const tenantId = resolveTenantId(modelName);
84
+ if (tenantId && Array.isArray(docs)) {
85
+ for (const doc of docs) {
86
+ if (!doc.tenantId) {
87
+ doc.tenantId = tenantId;
88
+ }
89
+ }
90
+ }
91
+ });
92
+
93
+ // === bulkWrite: filter queries and auto-set tenantId on inserts ===
94
+ schema.pre('bulkWrite', function (ops: any[]) {
95
+ // Model-level hooks: `this` is the Model class itself — modelName is a direct property
96
+ const modelName = this.modelName;
97
+ const tenantId = resolveTenantId(modelName);
98
+ if (tenantId === undefined) return;
99
+
100
+ for (const op of ops) {
101
+ if ('insertOne' in op) {
102
+ // Auto-set tenantId on insert (only if truthy, consistent with save hook)
103
+ if (tenantId && !op.insertOne.document.tenantId) {
104
+ op.insertOne.document.tenantId = tenantId;
105
+ }
106
+ } else if ('updateOne' in op) {
107
+ op.updateOne.filter = { ...op.updateOne.filter, tenantId };
108
+ } else if ('updateMany' in op) {
109
+ op.updateMany.filter = { ...op.updateMany.filter, tenantId };
110
+ } else if ('replaceOne' in op) {
111
+ op.replaceOne.filter = { ...op.replaceOne.filter, tenantId };
112
+ } else if ('deleteOne' in op) {
113
+ op.deleteOne.filter = { ...op.deleteOne.filter, tenantId };
114
+ } else if ('deleteMany' in op) {
115
+ op.deleteMany.filter = { ...op.deleteMany.filter, tenantId };
116
+ }
117
+ }
118
+ });
119
+
120
+ // === Aggregate: prepend $match stage ===
121
+ schema.pre('aggregate', function () {
122
+ // Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
123
+ const modelName = (this as any)._model?.modelName;
124
+ const tenantId = resolveTenantId(modelName);
125
+ if (tenantId !== undefined) {
126
+ this.pipeline().unshift({ $match: { tenantId } });
127
+ }
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Resolve tenant ID from RequestContext.
133
+ *
134
+ * @returns
135
+ * - `undefined` → no filter should be applied
136
+ * - `string` → filter by this tenant ID
137
+ * - `null` → filter by `{ tenantId: null }` (user without tenant sees only unassigned data)
138
+ */
139
+ function resolveTenantId(modelName?: string): string | null | undefined {
140
+ // Defense-in-depth: check config even if plugin is registered
141
+ const mtConfig = ConfigService.configFastButReadOnly?.multiTenancy;
142
+ if (!mtConfig || mtConfig.enabled === false) return undefined;
143
+
144
+ const context = RequestContext.get();
145
+
146
+ // No RequestContext (system operation, cron, migration) → no filter
147
+ if (!context) return undefined;
148
+
149
+ // Explicit bypass
150
+ if (context.bypassTenantGuard) return undefined;
151
+
152
+ // Check excluded schemas (model names, e.g. ['User', 'Session'])
153
+ if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return undefined;
154
+
155
+ const tenantId = context.tenantId;
156
+
157
+ // User has tenantId → filter by it (empty string is treated as falsy = no tenant)
158
+ if (tenantId) return tenantId;
159
+
160
+ // User is logged in but has no tenantId (undefined, null, or '') → null filter (sees only data without tenant)
161
+ if (context.currentUser) return null;
162
+
163
+ // No user (public endpoint) → no filter
164
+ return undefined;
165
+ }
@@ -9,6 +9,10 @@ export interface IRequestContext {
9
9
  language?: string;
10
10
  /** When true, mongooseRoleGuardPlugin allows role changes regardless of user permissions */
11
11
  bypassRoleGuard?: boolean;
12
+ /** When true, mongooseTenantPlugin skips tenant filtering */
13
+ bypassTenantGuard?: boolean;
14
+ /** Tenant ID resolved from the current user */
15
+ tenantId?: string;
12
16
  }
13
17
 
14
18
  /**
@@ -66,4 +70,37 @@ export class RequestContext {
66
70
  };
67
71
  return this.storage.run(context, fn);
68
72
  }
73
+
74
+ static getTenantId(): string | undefined {
75
+ return this.storage.getStore()?.tenantId;
76
+ }
77
+
78
+ static isBypassTenantGuard(): boolean {
79
+ return this.storage.getStore()?.bypassTenantGuard === true;
80
+ }
81
+
82
+ /**
83
+ * Run a function with tenant guard bypass enabled.
84
+ * The current context is preserved; only bypassTenantGuard is added.
85
+ *
86
+ * Use this for cross-tenant operations, e.g.:
87
+ * - Admin dashboards viewing all tenants
88
+ * - Cron jobs processing data across tenants
89
+ * - Migration scripts
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * const allOrders = await RequestContext.runWithBypassTenantGuard(async () => {
94
+ * return this.orderService.find();
95
+ * });
96
+ * ```
97
+ */
98
+ static runWithBypassTenantGuard<T>(fn: () => T): T {
99
+ const currentStore = this.storage.getStore();
100
+ const context: IRequestContext = {
101
+ ...currentStore,
102
+ bypassTenantGuard: true,
103
+ };
104
+ return this.storage.run(context, fn);
105
+ }
69
106
  }
@@ -344,7 +344,9 @@ export class CoreBetterAuthResolver {
344
344
  // 3. session.token (session-based fallback)
345
345
  const tokenResponse = response as any;
346
346
  const rawToken =
347
- tokenResponse.accessToken || tokenResponse.token || (hasSession(response) ? response.session.token : undefined);
347
+ tokenResponse.accessToken ||
348
+ tokenResponse.token ||
349
+ (hasSession(response) ? response.session.token : undefined);
348
350
  const token = await this.resolveJwtToken(rawToken);
349
351
 
350
352
  return {