@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.
- package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
- package/dist/core/common/decorators/restricted.decorator.js +4 -1
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/helpers/db.helper.d.ts +1 -1
- package/dist/core/common/helpers/db.helper.js +10 -4
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -1
- package/dist/core/common/helpers/input.helper.js +6 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +13 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -1
- package/dist/core/common/middleware/request-context.middleware.js +10 -6
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +3 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.js +6 -10
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
- package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
- package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
- package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
- package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
- package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
- package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +13 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +162 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
- package/dist/core/modules/tenant/core-tenant.module.js +58 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.service.d.ts +17 -0
- package/dist/core/modules/tenant/core-tenant.service.js +160 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
- package/dist/core.module.js +11 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +12 -10
- package/src/core/common/decorators/restricted.decorator.ts +12 -2
- package/src/core/common/helpers/db.helper.ts +13 -6
- package/src/core/common/helpers/input.helper.ts +6 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +17 -2
- package/src/core/common/interfaces/server-options.interface.ts +63 -30
- package/src/core/common/middleware/request-context.middleware.ts +12 -5
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
- package/src/core/common/services/request-context.service.ts +7 -1
- package/src/core/modules/auth/guards/roles.guard.ts +10 -10
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
- package/src/core/modules/tenant/README.md +232 -0
- package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
- package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
- package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +240 -0
- package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
- package/src/core/modules/tenant/core-tenant.module.ts +102 -0
- package/src/core/modules/tenant/core-tenant.service.ts +235 -0
- package/src/core.module.ts +15 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
141
|
-
"@vitest/ui": "4.0
|
|
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.
|
|
149
|
-
"oxlint": "1.
|
|
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
|
|
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.
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
1369
|
-
*
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
58
|
-
if (
|
|
59
|
-
this.where(
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
|
98
|
-
if (
|
|
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
|
|
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,
|
|
113
|
+
op.updateOne.filter = { ...op.updateOne.filter, ...filter };
|
|
108
114
|
} else if ('updateMany' in op) {
|
|
109
|
-
op.updateMany.filter = { ...op.updateMany.filter,
|
|
115
|
+
op.updateMany.filter = { ...op.updateMany.filter, ...filter };
|
|
110
116
|
} else if ('replaceOne' in op) {
|
|
111
|
-
op.replaceOne.filter = { ...op.replaceOne.filter,
|
|
117
|
+
op.replaceOne.filter = { ...op.replaceOne.filter, ...filter };
|
|
112
118
|
} else if ('deleteOne' in op) {
|
|
113
|
-
op.deleteOne.filter = { ...op.deleteOne.filter,
|
|
119
|
+
op.deleteOne.filter = { ...op.deleteOne.filter, ...filter };
|
|
114
120
|
} else if ('deleteMany' in op) {
|
|
115
|
-
op.deleteMany.filter = { ...op.deleteMany.filter,
|
|
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
|
|
125
|
-
if (
|
|
126
|
-
this.pipeline().unshift({ $match:
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
//
|
|
153
|
-
|
|
172
|
+
// Validated tenant ID (set by CoreTenantGuard) → filter by it
|
|
173
|
+
const tenantId = context?.tenantId;
|
|
174
|
+
if (tenantId) return { tenantId };
|
|
154
175
|
|
|
155
|
-
|
|
176
|
+
// User has resolved memberships → filter by their tenants
|
|
177
|
+
const tenantIds = context?.tenantIds;
|
|
178
|
+
if (tenantIds) return { tenantId: { $in: tenantIds } };
|
|
156
179
|
|
|
157
|
-
//
|
|
158
|
-
if (
|
|
180
|
+
// Admin bypass without header → no filter, sees all data
|
|
181
|
+
if (context?.isAdminBypass) return {};
|
|
159
182
|
|
|
160
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
/**
|