@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.
- package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
- package/dist/core/common/middleware/request-context.middleware.js +8 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +72 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -1
- package/dist/core/common/plugins/mongoose-password.plugin.js +35 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -1
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +61 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -1
- package/dist/core/common/plugins/mongoose-tenant.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +108 -0
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -0
- package/dist/core/common/services/request-context.service.d.ts +5 -0
- package/dist/core/common/services/request-context.service.js +14 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/error-code/core-error-code.controller.js +1 -1
- package/dist/core/modules/error-code/core-error-code.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core.module.js +4 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-code.controller.js +1 -1
- package/dist/server/modules/error-code/error-code.controller.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +11 -12
- package/src/core/common/interfaces/server-options.interface.ts +82 -2
- package/src/core/common/middleware/request-context.middleware.ts +8 -1
- package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +84 -0
- package/src/core/common/plugins/mongoose-password.plugin.ts +41 -1
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +65 -1
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +165 -0
- package/src/core/common/services/request-context.service.ts +37 -0
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +3 -1
- package/src/core/modules/error-code/INTEGRATION-CHECKLIST.md +8 -8
- package/src/core/modules/error-code/core-error-code.controller.ts +3 -3
- package/src/core/modules/error-code/interfaces/error-code.interfaces.ts +1 -1
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +5 -5
- package/src/core/modules/system-setup/README.md +9 -9
- package/src/core/modules/system-setup/core-system-setup.controller.ts +1 -1
- package/src/core.module.ts +5 -0
- package/src/index.ts +1 -0
- package/src/server/modules/error-code/README.md +5 -5
- 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.
|
|
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.
|
|
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.
|
|
112
|
-
"mongoose": "9.
|
|
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.
|
|
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.
|
|
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.
|
|
149
|
-
"oxlint": "1.
|
|
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: /
|
|
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: /
|
|
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
|
|
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(),
|
|
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 ||
|
|
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 {
|