@lenne.tech/nest-server 11.20.0 → 11.21.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 (71) hide show
  1. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  2. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  3. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  4. package/dist/core/common/helpers/db.helper.d.ts +1 -1
  5. package/dist/core/common/helpers/db.helper.js +10 -4
  6. package/dist/core/common/helpers/db.helper.js.map +1 -1
  7. package/dist/core/common/helpers/input.helper.d.ts +1 -1
  8. package/dist/core/common/helpers/input.helper.js +6 -2
  9. package/dist/core/common/helpers/input.helper.js.map +1 -1
  10. package/dist/core/common/interceptors/check-security.interceptor.js +13 -1
  11. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  12. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -1
  13. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  14. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  15. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  16. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  17. package/dist/core/common/services/request-context.service.d.ts +3 -0
  18. package/dist/core/common/services/request-context.service.js.map +1 -1
  19. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  20. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  21. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  22. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  23. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  24. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  25. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  27. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  28. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  29. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  30. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  31. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  32. package/dist/core/modules/tenant/core-tenant.guard.d.ts +13 -0
  33. package/dist/core/modules/tenant/core-tenant.guard.js +162 -0
  34. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  35. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  36. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  37. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  38. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  39. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  40. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  41. package/dist/core/modules/tenant/core-tenant.service.d.ts +17 -0
  42. package/dist/core/modules/tenant/core-tenant.service.js +160 -0
  43. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  44. package/dist/core.module.js +11 -0
  45. package/dist/core.module.js.map +1 -1
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +7 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/tsconfig.build.tsbuildinfo +1 -1
  50. package/package.json +12 -10
  51. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  52. package/src/core/common/helpers/db.helper.ts +13 -6
  53. package/src/core/common/helpers/input.helper.ts +6 -2
  54. package/src/core/common/interceptors/check-security.interceptor.ts +17 -2
  55. package/src/core/common/interfaces/server-options.interface.ts +63 -30
  56. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  57. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  58. package/src/core/common/services/request-context.service.ts +7 -1
  59. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  60. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  61. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  62. package/src/core/modules/tenant/README.md +232 -0
  63. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  64. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  65. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  66. package/src/core/modules/tenant/core-tenant.guard.ts +240 -0
  67. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  68. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  69. package/src/core/modules/tenant/core-tenant.service.ts +235 -0
  70. package/src/core.module.ts +15 -0
  71. package/src/index.ts +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.20.0",
3
+ "version": "11.21.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",
@@ -75,7 +75,7 @@
75
75
  "dependencies": {
76
76
  "@apollo/server": "5.4.0",
77
77
  "@as-integrations/express5": "1.1.2",
78
- "@better-auth/passkey": "1.5.4",
78
+ "@better-auth/passkey": "1.5.5",
79
79
  "@getbrevo/brevo": "3.0.1",
80
80
  "@nestjs/apollo": "13.2.4",
81
81
  "@nestjs/common": "11.1.16",
@@ -92,7 +92,7 @@
92
92
  "@tus/file-store": "2.0.0",
93
93
  "@tus/server": "2.3.0",
94
94
  "bcrypt": "6.0.0",
95
- "better-auth": "1.5.4",
95
+ "better-auth": "1.5.5",
96
96
  "class-transformer": "0.5.1",
97
97
  "class-validator": "0.15.1",
98
98
  "compression": "1.8.1",
@@ -133,20 +133,20 @@
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.4.0",
136
+ "@types/node": "25.5.0",
137
137
  "@types/nodemailer": "7.0.11",
138
138
  "@types/passport": "1.0.17",
139
139
  "@types/supertest": "7.2.0",
140
- "@vitest/coverage-v8": "4.0.18",
141
- "@vitest/ui": "4.0.18",
140
+ "@vitest/coverage-v8": "4.1.0",
141
+ "@vitest/ui": "4.1.0",
142
142
  "ansi-colors": "4.1.3",
143
143
  "find-file-up": "2.0.1",
144
144
  "husky": "9.1.7",
145
145
  "nodemon": "3.1.14",
146
146
  "npm-watch": "0.13.0",
147
147
  "otpauth": "9.5.0",
148
- "oxfmt": "0.38.0",
149
- "oxlint": "1.53.0",
148
+ "oxfmt": "0.40.0",
149
+ "oxlint": "1.55.0",
150
150
  "rimraf": "6.1.3",
151
151
  "supertest": "7.2.2",
152
152
  "ts-node": "10.9.2",
@@ -157,7 +157,7 @@
157
157
  "vite": "7.3.1",
158
158
  "vite-plugin-node": "7.0.0",
159
159
  "vite-tsconfig-paths": "6.1.1",
160
- "vitest": "4.0.18"
160
+ "vitest": "4.1.0"
161
161
  },
162
162
  "main": "dist/index.js",
163
163
  "types": "dist/index.d.ts",
@@ -182,7 +182,9 @@
182
182
  "rollup@>=4.0.0 <4.59.0": "4.59.0",
183
183
  "ajv@<6.14.0": "6.14.0",
184
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"
185
+ "file-type@>=13.0.0 <21.3.2": "21.3.2",
186
+ "undici@>=7.0.0 <7.24.0": "7.24.3",
187
+ "yauzl@<3.2.1": "3.2.1"
186
188
  },
187
189
  "onlyBuiltDependencies": [
188
190
  "bcrypt",
@@ -5,7 +5,9 @@ import _ = require('lodash');
5
5
  import { ProcessType } from '../enums/process-type.enum';
6
6
  import { RoleEnum } from '../enums/role.enum';
7
7
  import { equalIds, getIncludedIds } from '../helpers/db.helper';
8
+ import { RequestContext } from '../services/request-context.service';
8
9
  import { RequireAtLeastOne } from '../types/required-at-least-one.type';
10
+ import { checkRoleAccess } from '../../modules/tenant/core-tenant.helpers';
9
11
 
10
12
  /**
11
13
  * Restricted meta key
@@ -61,7 +63,14 @@ export const getRestricted = (object: unknown, propertyKey?: string): Restricted
61
63
  */
62
64
  export const checkRestricted = (
63
65
  data: any,
64
- user: { emailVerified?: any; hasRole: (roles: string[]) => boolean; id: any; verified?: any; verifiedAt?: any },
66
+ user: {
67
+ emailVerified?: any;
68
+ hasRole: (roles: string[]) => boolean;
69
+ id: any;
70
+ roles?: string[];
71
+ verified?: any;
72
+ verifiedAt?: any;
73
+ },
65
74
  options: {
66
75
  allowCreatorOfParent?: boolean;
67
76
  checkObjectItself?: boolean;
@@ -163,7 +172,8 @@ export const checkRestricted = (
163
172
  (roles.includes(RoleEnum.S_CREATOR) &&
164
173
  (('createdBy' in data && equalIds(data.createdBy, user)) ||
165
174
  (config.allowCreatorOfParent && !('createdBy' in data) && config.isCreatorOfParent))) ||
166
- (roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified))
175
+ (roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified)) ||
176
+ (user?.id && checkRoleAccess(roles, user?.roles, RequestContext.get()?.tenantRole))
167
177
  ) {
168
178
  valid = true;
169
179
  }
@@ -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
  }
@@ -109,24 +109,39 @@ export class CheckSecurityInterceptor implements NestInterceptor {
109
109
  // Fallback: Remove known secret fields regardless of model type (recursive into plain objects)
110
110
  const isPlainLike = (val: any): boolean => {
111
111
  if (!val || typeof val !== 'object' || Array.isArray(val)) return false;
112
- // Skip Streams, Buffers, Dates, RegExps and other special objects
112
+ // Skip Streams, Buffers, Dates, RegExps, Maps, Sets
113
113
  if (typeof val.pipe === 'function') return false;
114
114
  if (Buffer.isBuffer(val)) return false;
115
115
  if (val instanceof Date || val instanceof RegExp) return false;
116
+ if (val instanceof Map || val instanceof Set) return false;
117
+ // Skip Mongoose documents and BSON types (they have circular internal references)
118
+ if (val.$__ !== undefined || val._bsontype !== undefined) return false;
116
119
  const proto = Object.getPrototypeOf(val);
117
- return proto === null || proto === Object.prototype || typeof val.constructor === 'function';
120
+ // Only recurse into actual plain objects (created via {} or Object.create(null)).
121
+ // Previously used `typeof val.constructor === 'function'` which was too broad and
122
+ // caused infinite recursion on Mongoose Schema.Types.Mixed fields whose internal
123
+ // objects (Schema, SchemaType) have circular references.
124
+ return proto === null || proto === Object.prototype;
118
125
  };
126
+ const visited = new WeakSet();
119
127
  const removeSecrets = (data: any) => {
120
128
  if (!this.config.removeSecretFields || !data || typeof data !== 'object') {
121
129
  return data;
122
130
  }
123
131
  if (Array.isArray(data)) {
132
+ if (visited.has(data)) return data;
133
+ visited.add(data);
124
134
  data.forEach(removeSecrets);
125
135
  return data;
126
136
  }
127
137
  if (!isPlainLike(data)) {
128
138
  return data;
129
139
  }
140
+ // Prevent infinite recursion on circular references
141
+ if (visited.has(data)) {
142
+ return data;
143
+ }
144
+ visited.add(data);
130
145
  for (const field of this.config.secretFields) {
131
146
  if (field in data && data[field] !== undefined) {
132
147
  data[field] = undefined;
@@ -817,15 +817,12 @@ export interface IJwt {
817
817
  }
818
818
 
819
819
  /**
820
- * Multi-tenancy configuration
820
+ * Multi-tenancy configuration for automatic tenant-based data isolation.
821
821
  *
822
822
  * Follows the "presence implies enabled" pattern:
823
823
  * - `undefined`: Feature disabled (no overhead)
824
824
  * - `{}`: Feature enabled with defaults
825
825
  * - `{ enabled: false }`: Pre-configured but disabled
826
- */
827
- /**
828
- * Multi-tenancy configuration for automatic tenant-based data isolation.
829
826
  *
830
827
  * @since 11.20.0
831
828
  */
@@ -837,27 +834,67 @@ export interface IMultiTenancy {
837
834
  */
838
835
  enabled?: boolean;
839
836
 
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
837
  /**
856
838
  * Model names (NOT collection names) to exclude from tenant filtering.
857
839
  * These schemas will not have tenant isolation applied.
840
+ * The TenantMember model is always excluded automatically.
858
841
  * @example ['User', 'Session']
859
842
  */
860
843
  excludeSchemas?: string[];
844
+
845
+ /**
846
+ * Header name for tenant selection.
847
+ * The header value contains the tenant ID for the current request.
848
+ * @default 'x-tenant-id'
849
+ * @since 11.21.0
850
+ */
851
+ headerName?: string;
852
+
853
+ /**
854
+ * Mongoose model name for the membership collection.
855
+ * Must be registered via MongooseModule.forFeature().
856
+ * @default 'TenantMember'
857
+ * @since 11.21.0
858
+ */
859
+ membershipModel?: string;
860
+
861
+ /**
862
+ * Whether system admins (RoleEnum.ADMIN) bypass the membership check.
863
+ * When true, admins can access any tenant without being a member.
864
+ * @default true
865
+ * @since 11.21.0
866
+ */
867
+ adminBypass?: boolean;
868
+
869
+ /**
870
+ * Custom role hierarchy for tenant membership roles.
871
+ * Keys = role names (stored in membership documents), values = numeric levels.
872
+ * Higher value = more privileges. Multiple roles can share the same level.
873
+ *
874
+ * Hierarchy roles use level comparison: a higher level includes all lower levels.
875
+ * Roles NOT in this config are treated as "normal roles" with exact match semantics.
876
+ *
877
+ * Use `createHierarchyRoles(hierarchy)` to generate type-safe constants for decorators.
878
+ *
879
+ * @default { member: 1, manager: 2, owner: 3 }
880
+ * @since 11.21.0
881
+ *
882
+ * @example
883
+ * ```typescript
884
+ * // config.env.ts
885
+ * multiTenancy: {
886
+ * roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 }
887
+ * }
888
+ *
889
+ * // roles.ts
890
+ * import { createHierarchyRoles } from '@lenne.tech/nest-server';
891
+ * export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 });
892
+ *
893
+ * // resolver.ts
894
+ * @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, admin, owner)
895
+ * ```
896
+ */
897
+ roleHierarchy?: Record<string, number>;
861
898
  }
862
899
 
863
900
  /**
@@ -1365,19 +1402,19 @@ export interface IServerOptions {
1365
1402
  /**
1366
1403
  * Multi-tenancy configuration for tenant-based data isolation.
1367
1404
  *
1368
- * When enabled, a global Mongoose plugin automatically filters all queries
1369
- * by `tenantId`, ensuring tenant isolation in a shared database.
1405
+ * When enabled, provides header-based tenant selection with membership validation.
1406
+ * The active tenant is determined by the X-Tenant-Id header on each request.
1407
+ * A global Mongoose plugin automatically filters all queries by `tenantId`.
1370
1408
  *
1371
1409
  * Follows the "presence implies enabled" pattern:
1372
1410
  * - `undefined`: Disabled (no overhead, backward compatible)
1373
- * - `{}`: Enabled with defaults
1374
- * - `{ userField: 'organizationId' }`: Enabled with custom user field
1411
+ * - `{}`: Enabled with defaults (X-Tenant-Id header, admin bypass)
1375
1412
  * - `{ enabled: false }`: Pre-configured but disabled
1376
1413
  *
1377
1414
  * The plugin activates automatically on any schema that has a `tenantId` field.
1378
1415
  * Schemas without `tenantId` are not affected.
1379
1416
  *
1380
- * @since 11.20.0
1417
+ * @since 11.21.0
1381
1418
  * @default undefined (disabled)
1382
1419
  *
1383
1420
  * @example
@@ -1385,14 +1422,10 @@ export interface IServerOptions {
1385
1422
  * // Enable with defaults
1386
1423
  * multiTenancy: {},
1387
1424
  *
1388
- * // Enable with excluded schemas
1425
+ * // Enable with excluded schemas and custom header
1389
1426
  * multiTenancy: {
1390
1427
  * excludeSchemas: ['User', 'Session'],
1391
- * },
1392
- *
1393
- * // Custom user field
1394
- * multiTenancy: {
1395
- * userField: 'organizationId',
1428
+ * headerName: 'x-tenant-id',
1396
1429
  * },
1397
1430
  * ```
1398
1431
  */
@@ -1,7 +1,6 @@
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';
5
4
  import { IRequestContext, RequestContext } from '../services/request-context.service';
6
5
 
7
6
  /**
@@ -21,10 +20,18 @@ export class RequestContextMiddleware implements NestMiddleware {
21
20
  return req.headers?.['accept-language'] || undefined;
22
21
  },
23
22
  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;
23
+ // Only return tenant ID set by CoreTenantGuard (after membership validation).
24
+ // The raw header is NEVER used for plugin filtering.
25
+ return (req as any).tenantId ?? undefined;
26
+ },
27
+ get tenantIds() {
28
+ return (req as any).tenantIds ?? undefined;
29
+ },
30
+ get tenantRole() {
31
+ return (req as any).tenantRole ?? undefined;
32
+ },
33
+ get isAdminBypass() {
34
+ return (req as any).isAdminBypass ?? false;
28
35
  },
29
36
  };
30
37
  RequestContext.run(context, () => next());
@@ -1,3 +1,5 @@
1
+ import { ForbiddenException } from '@nestjs/common';
2
+
1
3
  import { ConfigService } from '../services/config.service';
2
4
  import { RequestContext } from '../services/request-context.service';
3
5
 
@@ -15,18 +17,21 @@ import { RequestContext } from '../services/request-context.service';
15
17
  * - New documents get tenantId set automatically from context
16
18
  * - Aggregates get a $match stage prepended
17
19
  *
20
+ * **Filter modes:**
21
+ * - X-Tenant-Id header set → `{ tenantId: headerValue }` (single tenant)
22
+ * - No header + authenticated user → `{ tenantId: { $in: userTenantIds } }` (all user's tenants)
23
+ * - No header + no user → no filter (public/system routes)
24
+ *
18
25
  * **No filter applied when:**
19
26
  * - No RequestContext (system operations, cron jobs, migrations)
20
27
  * - `bypassTenantGuard` is active (via `RequestContext.runWithBypassTenantGuard()`)
21
28
  * - 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
29
  */
28
30
  export function mongooseTenantPlugin(schema) {
29
- // Only activate on schemas with a tenantId path
31
+ // Only activate on schemas with a tenantId path.
32
+ // CoreTenantMemberModel uses 'tenant' (not 'tenantId') intentionally, so this check
33
+ // excludes it at registration time. Additionally, 'TenantMember' is auto-added to
34
+ // excludeSchemas in CoreModule as defense-in-depth (see shouldBypass()).
30
35
  if (!schema.path('tenantId')) {
31
36
  return;
32
37
  }
@@ -54,22 +59,21 @@ export function mongooseTenantPlugin(schema) {
54
59
  schema.pre(hookName, function () {
55
60
  // Query hooks: `this` is a Mongoose Query — modelName is on `this.model`
56
61
  const modelName = this.model?.modelName;
57
- const tenantId = resolveTenantId(modelName);
58
- if (tenantId !== undefined) {
59
- this.where({ tenantId });
62
+ const filter = resolveTenantFilter(modelName);
63
+ if (filter !== undefined) {
64
+ this.where(filter);
60
65
  }
61
66
  });
62
67
  }
63
68
 
64
69
  // === Save: set tenantId automatically on new documents ===
65
70
  // 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.
71
+ // Only uses single tenantId from header tenantIds array is for reads only.
68
72
  schema.pre('save', function () {
69
73
  if (this.isNew && !this['tenantId']) {
70
74
  // Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
71
75
  const modelName = (this.constructor as any).modelName;
72
- const tenantId = resolveTenantId(modelName);
76
+ const tenantId = resolveSingleTenantId(modelName);
73
77
  if (tenantId) {
74
78
  this['tenantId'] = tenantId;
75
79
  }
@@ -80,7 +84,7 @@ export function mongooseTenantPlugin(schema) {
80
84
  schema.pre('insertMany', function (docs: any[]) {
81
85
  // Model-level hooks: `this` is the Model class itself — modelName is a direct property
82
86
  const modelName = this.modelName;
83
- const tenantId = resolveTenantId(modelName);
87
+ const tenantId = resolveSingleTenantId(modelName);
84
88
  if (tenantId && Array.isArray(docs)) {
85
89
  for (const doc of docs) {
86
90
  if (!doc.tenantId) {
@@ -94,25 +98,27 @@ export function mongooseTenantPlugin(schema) {
94
98
  schema.pre('bulkWrite', function (ops: any[]) {
95
99
  // Model-level hooks: `this` is the Model class itself — modelName is a direct property
96
100
  const modelName = this.modelName;
97
- const tenantId = resolveTenantId(modelName);
98
- if (tenantId === undefined) return;
101
+ const filter = resolveTenantFilter(modelName);
102
+ if (filter === undefined) return;
103
+
104
+ const tenantId = resolveSingleTenantId(modelName);
99
105
 
100
106
  for (const op of ops) {
101
107
  if ('insertOne' in op) {
102
- // Auto-set tenantId on insert (only if truthy, consistent with save hook)
108
+ // Auto-set tenantId on insert (only single tenantId, consistent with save hook)
103
109
  if (tenantId && !op.insertOne.document.tenantId) {
104
110
  op.insertOne.document.tenantId = tenantId;
105
111
  }
106
112
  } else if ('updateOne' in op) {
107
- op.updateOne.filter = { ...op.updateOne.filter, tenantId };
113
+ op.updateOne.filter = { ...op.updateOne.filter, ...filter };
108
114
  } else if ('updateMany' in op) {
109
- op.updateMany.filter = { ...op.updateMany.filter, tenantId };
115
+ op.updateMany.filter = { ...op.updateMany.filter, ...filter };
110
116
  } else if ('replaceOne' in op) {
111
- op.replaceOne.filter = { ...op.replaceOne.filter, tenantId };
117
+ op.replaceOne.filter = { ...op.replaceOne.filter, ...filter };
112
118
  } else if ('deleteOne' in op) {
113
- op.deleteOne.filter = { ...op.deleteOne.filter, tenantId };
119
+ op.deleteOne.filter = { ...op.deleteOne.filter, ...filter };
114
120
  } else if ('deleteMany' in op) {
115
- op.deleteMany.filter = { ...op.deleteMany.filter, tenantId };
121
+ op.deleteMany.filter = { ...op.deleteMany.filter, ...filter };
116
122
  }
117
123
  }
118
124
  });
@@ -121,45 +127,72 @@ export function mongooseTenantPlugin(schema) {
121
127
  schema.pre('aggregate', function () {
122
128
  // Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
123
129
  const modelName = (this as any)._model?.modelName;
124
- const tenantId = resolveTenantId(modelName);
125
- if (tenantId !== undefined) {
126
- this.pipeline().unshift({ $match: { tenantId } });
130
+ const filter = resolveTenantFilter(modelName);
131
+ if (filter !== undefined) {
132
+ this.pipeline().unshift({ $match: filter });
127
133
  }
128
134
  });
129
135
  }
130
136
 
131
137
  /**
132
- * Resolve tenant ID from RequestContext.
138
+ * Check common bypass conditions.
133
139
  *
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)
140
+ * @returns `true` if filtering should be skipped, `false` otherwise
138
141
  */
139
- function resolveTenantId(modelName?: string): string | null | undefined {
140
- // Defense-in-depth: check config even if plugin is registered
142
+ function shouldBypass(modelName?: string): boolean {
141
143
  const mtConfig = ConfigService.configFastButReadOnly?.multiTenancy;
142
- if (!mtConfig || mtConfig.enabled === false) return undefined;
144
+ if (!mtConfig || mtConfig.enabled === false) return true;
143
145
 
144
146
  const context = RequestContext.get();
147
+ if (!context) return true;
148
+ if (context.bypassTenantGuard) return true;
149
+ if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return true;
145
150
 
146
- // No RequestContext (system operation, cron, migration) → no filter
147
- if (!context) return undefined;
151
+ return false;
152
+ }
148
153
 
149
- // Explicit bypass
150
- if (context.bypassTenantGuard) return undefined;
154
+ /**
155
+ * Resolve tenant filter from RequestContext for read operations (queries, aggregates).
156
+ *
157
+ * Defense-in-depth: If a schema has tenantId but there is no valid tenant context,
158
+ * throws ForbiddenException instead of returning unfiltered data (Safety Net).
159
+ *
160
+ * @returns
161
+ * - `undefined` → no filter should be applied (bypass active or plugin disabled)
162
+ * - `{}` → empty filter (admin bypass without header — sees all data)
163
+ * - `{ tenantId: string }` → filter by single validated tenant
164
+ * - `{ tenantId: { $in: string[] } }` → filter by user's tenant memberships
165
+ * @throws ForbiddenException when tenantId-schema is accessed without valid tenant context
166
+ */
167
+ function resolveTenantFilter(modelName?: string): Record<string, any> | undefined {
168
+ if (shouldBypass(modelName)) return undefined;
169
+
170
+ const context = RequestContext.get();
151
171
 
152
- // Check excluded schemas (model names, e.g. ['User', 'Session'])
153
- if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return undefined;
172
+ // Validated tenant ID (set by CoreTenantGuard) filter by it
173
+ const tenantId = context?.tenantId;
174
+ if (tenantId) return { tenantId };
154
175
 
155
- const tenantId = context.tenantId;
176
+ // User has resolved memberships → filter by their tenants
177
+ const tenantIds = context?.tenantIds;
178
+ if (tenantIds) return { tenantId: { $in: tenantIds } };
156
179
 
157
- // User has tenantId → filter by it (empty string is treated as falsy = no tenant)
158
- if (tenantId) return tenantId;
180
+ // Admin bypass without header no filter, sees all data
181
+ if (context?.isAdminBypass) return {};
159
182
 
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;
183
+ // SAFETY NET: Schema has tenantId but no valid tenant context.
184
+ // Throw instead of returning unfiltered data to prevent data leaks.
185
+ throw new ForbiddenException(
186
+ 'Tenant context required: this data is tenant-scoped but no valid tenant context was provided',
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Resolve single tenant ID for write operations (save, insertMany).
192
+ * Only returns a value when a specific tenant header is set.
193
+ */
194
+ function resolveSingleTenantId(modelName?: string): string | undefined {
195
+ if (shouldBypass(modelName)) return undefined;
162
196
 
163
- // No user (public endpoint) no filter
164
- return undefined;
197
+ return RequestContext.get()?.tenantId || undefined;
165
198
  }
@@ -11,8 +11,14 @@ export interface IRequestContext {
11
11
  bypassRoleGuard?: boolean;
12
12
  /** When true, mongooseTenantPlugin skips tenant filtering */
13
13
  bypassTenantGuard?: boolean;
14
- /** Tenant ID resolved from the current user */
14
+ /** Validated tenant ID (set by CoreTenantGuard after membership validation, not raw header) */
15
15
  tenantId?: string;
16
+ /** Tenant IDs from user's active tenant memberships (used when no specific header is set) */
17
+ tenantIds?: string[];
18
+ /** Tenant role of the current user in the active tenant */
19
+ tenantRole?: string;
20
+ /** When true, indicates admin bypass is active (admin without header sees all data) */
21
+ isAdminBypass?: boolean;
16
22
  }
17
23
 
18
24
  /**