@lenne.tech/nest-server 11.22.0 → 11.23.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/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/framework-compatibility.md +79 -0
- package/CLAUDE.md +60 -0
- package/FRAMEWORK-API.md +235 -0
- package/dist/core/common/decorators/restricted.decorator.js +21 -4
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/common/services/crud.service.d.ts +4 -1
- package/dist/core/common/services/crud.service.js +24 -2
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/core/common/services/module.service.d.ts +3 -2
- package/dist/core/common/services/module.service.js +43 -20
- package/dist/core/common/services/module.service.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 +12 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/server/modules/file/file-info.model.d.ts +1 -5
- package/dist/server/modules/user/user.model.d.ts +1 -5
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +25 -2
- package/docs/native-driver-security.md +153 -0
- package/docs/process-performance-optimization.md +493 -0
- package/migration-guides/11.22.0-to-11.22.1.md +105 -0
- package/migration-guides/11.22.x-to-11.23.0.md +235 -0
- package/package.json +33 -31
- package/src/core/common/decorators/restricted.decorator.ts +44 -4
- package/src/core/common/interfaces/server-options.interface.ts +8 -0
- package/src/core/common/services/crud.service.ts +77 -5
- package/src/core/common/services/module.service.ts +96 -35
- package/src/core/common/services/request-context.service.ts +47 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { NotFoundException } from '@nestjs/common';
|
|
1
|
+
import { Logger, NotFoundException } from '@nestjs/common';
|
|
2
2
|
import {
|
|
3
3
|
AggregateOptions,
|
|
4
|
+
Collection,
|
|
5
|
+
Connection,
|
|
4
6
|
Document,
|
|
5
7
|
Model as MongooseModel,
|
|
6
8
|
PipelineStage,
|
|
@@ -76,7 +78,11 @@ export abstract class CrudService<
|
|
|
76
78
|
return this.process(
|
|
77
79
|
async (data) => {
|
|
78
80
|
const currentUserId = serviceOptions?.currentUser?.id;
|
|
79
|
-
return new this.mainDbModel
|
|
81
|
+
return new (this.mainDbModel as MongooseModel<Document & Model>)({
|
|
82
|
+
...data.input,
|
|
83
|
+
createdBy: currentUserId,
|
|
84
|
+
updatedBy: currentUserId,
|
|
85
|
+
}).save();
|
|
80
86
|
},
|
|
81
87
|
{ input, serviceOptions },
|
|
82
88
|
);
|
|
@@ -314,7 +320,7 @@ export abstract class CrudService<
|
|
|
314
320
|
filter?: FilterArgs | { filterQuery?: QueryFilter<any>; queryOptions?: QueryOptions; samples?: number },
|
|
315
321
|
serviceOptions: ServiceOptions = {},
|
|
316
322
|
): Promise<{ items: Model[]; pagination: PaginationInfo; totalCount: number }> {
|
|
317
|
-
serviceOptions.
|
|
323
|
+
serviceOptions.force = true;
|
|
318
324
|
return this.findAndCount(filter, serviceOptions);
|
|
319
325
|
}
|
|
320
326
|
|
|
@@ -443,11 +449,77 @@ export abstract class CrudService<
|
|
|
443
449
|
}
|
|
444
450
|
|
|
445
451
|
/**
|
|
446
|
-
* Get service model to process queries directly
|
|
452
|
+
* Get service model to process queries directly.
|
|
447
453
|
* See https://mongoosejs.com/docs/api/model.html
|
|
454
|
+
*
|
|
455
|
+
* Note: Returns the full Mongoose Model (including `.collection`).
|
|
456
|
+
* Prefer using CrudService methods or Mongoose Model methods directly
|
|
457
|
+
* to ensure plugins (Tenant, Audit, RoleGuard) fire correctly.
|
|
448
458
|
*/
|
|
449
459
|
getModel(): MongooseModel<Document & Model> {
|
|
450
|
-
return this.mainDbModel
|
|
460
|
+
return this.mainDbModel as unknown as MongooseModel<Document & Model>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get the native MongoDB Collection, bypassing all Mongoose plugins.
|
|
465
|
+
*
|
|
466
|
+
* **WARNING:** Native driver access bypasses Tenant-Isolation, Audit-Fields,
|
|
467
|
+
* RoleGuard, and Password-Hashing plugins. Only use this when Mongoose
|
|
468
|
+
* Model methods cannot achieve the goal (e.g., bulk imports, migrations).
|
|
469
|
+
*
|
|
470
|
+
* Every call logs a `[SECURITY]` warning with the provided reason.
|
|
471
|
+
*
|
|
472
|
+
* @param reason - Mandatory justification for native access (logged for audit trail)
|
|
473
|
+
* @throws Error if reason is empty
|
|
474
|
+
*
|
|
475
|
+
* @example
|
|
476
|
+
* ```typescript
|
|
477
|
+
* const col = this.getNativeCollection('Migration: bulk-import historical data without tenant context');
|
|
478
|
+
* await col.insertMany(legacyDocs);
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
protected getNativeCollection(reason: string): Collection {
|
|
482
|
+
this.validateNativeAccessReason(reason, 'getNativeCollection');
|
|
483
|
+
|
|
484
|
+
const modelName = (this.mainDbModel as any)?.modelName || 'Unknown';
|
|
485
|
+
Logger.warn(`[SECURITY] Native collection access: ${reason} (Model: ${modelName})`, this.constructor.name);
|
|
486
|
+
|
|
487
|
+
return (this.mainDbModel as unknown as MongooseModel<Document & Model>).collection;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get the Mongoose Connection (which provides access to the native MongoDB Db and MongoClient).
|
|
492
|
+
*
|
|
493
|
+
* **WARNING:** Via `connection.db` you get the native MongoDB Db instance,
|
|
494
|
+
* and via `connection.getClient()` the native MongoClient. Both bypass ALL
|
|
495
|
+
* Mongoose plugins (Tenant-Isolation, Audit-Fields, RoleGuard, Password-Hashing).
|
|
496
|
+
*
|
|
497
|
+
* Every call logs a `[SECURITY]` warning with the provided reason.
|
|
498
|
+
*
|
|
499
|
+
* @param reason - Mandatory justification (min 20 chars) for native access (logged for audit trail)
|
|
500
|
+
* @throws Error if reason is empty or too short
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```typescript
|
|
504
|
+
* // Read-only cross-collection count (no Mongoose schema for target collection)
|
|
505
|
+
* const conn = this.getNativeConnection('Statistics: count chatmessages across all tenants');
|
|
506
|
+
* const count = await conn.db.collection('chatmessages').countDocuments({ ... });
|
|
507
|
+
* ```
|
|
508
|
+
*/
|
|
509
|
+
protected getNativeConnection(reason: string): Connection {
|
|
510
|
+
this.validateNativeAccessReason(reason, 'getNativeConnection');
|
|
511
|
+
|
|
512
|
+
const modelName = (this.mainDbModel as any)?.modelName || 'Unknown';
|
|
513
|
+
Logger.warn(`[SECURITY] Native connection access: ${reason} (Model: ${modelName})`, this.constructor.name);
|
|
514
|
+
|
|
515
|
+
return (this.mainDbModel as unknown as MongooseModel<Document & Model>).db;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private validateNativeAccessReason(reason: string, method: string): void {
|
|
519
|
+
const trimmed = reason?.trim();
|
|
520
|
+
if (!trimmed || trimmed.length < 20) {
|
|
521
|
+
throw new Error(`${method} requires a meaningful reason (min 20 chars) — explain why native access is needed`);
|
|
522
|
+
}
|
|
451
523
|
}
|
|
452
524
|
|
|
453
525
|
/**
|
|
@@ -13,6 +13,19 @@ import { ConfigService } from './config.service';
|
|
|
13
13
|
import { ModelRegistry } from './model-registry.service';
|
|
14
14
|
import { RequestContext } from './request-context.service';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Mongoose Model with native driver access paths removed to prevent
|
|
18
|
+
* accidental bypass of Mongoose plugins (Tenant, Audit, RoleGuard, Password).
|
|
19
|
+
*
|
|
20
|
+
* Blocked:
|
|
21
|
+
* - `.collection` — native MongoDB Collection (bypasses ALL plugins)
|
|
22
|
+
* - `.db` — Mongoose Connection → `.db` (native Db) → `.getClient()` (native MongoClient)
|
|
23
|
+
*
|
|
24
|
+
* Use `getNativeCollection(reason)` or `getNativeConnection(reason)` in CrudService
|
|
25
|
+
* for legitimate native access (logged, requires justification).
|
|
26
|
+
*/
|
|
27
|
+
export type SafeModel<T> = Omit<Model<T>, 'collection' | 'db'>;
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* Module service class to be extended by concrete module services
|
|
18
31
|
*/
|
|
@@ -28,9 +41,15 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
28
41
|
protected mainModelConstructor: new (...args: any[]) => T;
|
|
29
42
|
|
|
30
43
|
/**
|
|
31
|
-
* Main DB model of the service, will be used as default for populate and mapping
|
|
44
|
+
* Main DB model of the service, will be used as default for populate and mapping.
|
|
45
|
+
*
|
|
46
|
+
* Typed as SafeModel to prevent direct access to the native MongoDB driver
|
|
47
|
+
* via `.collection`. All Mongoose plugins (Tenant, Audit, RoleGuard, Password)
|
|
48
|
+
* only fire on Model methods — native driver access bypasses them all.
|
|
49
|
+
*
|
|
50
|
+
* For legitimate native access, use `getNativeCollection(reason)` in CrudService.
|
|
32
51
|
*/
|
|
33
|
-
protected mainDbModel:
|
|
52
|
+
protected mainDbModel: SafeModel<Document & T>;
|
|
34
53
|
|
|
35
54
|
/**
|
|
36
55
|
* Set main properties
|
|
@@ -41,7 +60,7 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
41
60
|
mainModelConstructor?: new (...args: any[]) => T;
|
|
42
61
|
}) {
|
|
43
62
|
this.configService = options?.configService;
|
|
44
|
-
this.mainDbModel = options?.mainDbModel
|
|
63
|
+
this.mainDbModel = options?.mainDbModel as SafeModel<Document & T>;
|
|
45
64
|
this.mainModelConstructor = options?.mainModelConstructor;
|
|
46
65
|
|
|
47
66
|
// Auto-register model class for ResponseModelInterceptor lookups
|
|
@@ -107,6 +126,10 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
107
126
|
...options?.serviceOptions,
|
|
108
127
|
};
|
|
109
128
|
|
|
129
|
+
// Detect nested process() calls
|
|
130
|
+
const currentDepth = RequestContext.getProcessDepth();
|
|
131
|
+
const isNested = currentDepth > 0;
|
|
132
|
+
|
|
110
133
|
// Note raw configuration
|
|
111
134
|
if (config.raw) {
|
|
112
135
|
config.prepareInput = null;
|
|
@@ -146,31 +169,53 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
146
169
|
if (!opts.targetModel && config.inputType) {
|
|
147
170
|
opts.targetModel = config.inputType;
|
|
148
171
|
}
|
|
149
|
-
const originalInput = config.input;
|
|
150
|
-
const inputJSON = JSON.stringify(originalInput);
|
|
151
172
|
const preparedInput = await this.prepareInput(config.input, config);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
'
|
|
159
|
-
|
|
160
|
-
'
|
|
161
|
-
|
|
162
|
-
)
|
|
173
|
+
|
|
174
|
+
if (this.configService?.getFastButReadOnly('debugProcessInput', false)) {
|
|
175
|
+
try {
|
|
176
|
+
const secretFields: string[] = this.configService?.getFastButReadOnly('security.secretFields', [
|
|
177
|
+
'password',
|
|
178
|
+
'verificationToken',
|
|
179
|
+
'passwordResetToken',
|
|
180
|
+
'refreshTokens',
|
|
181
|
+
'tempTokens',
|
|
182
|
+
]);
|
|
183
|
+
const redact = (json: string) =>
|
|
184
|
+
secretFields.reduce(
|
|
185
|
+
(s, field) => s.replace(new RegExp(`"${field}":\\s*"[^"]*"`, 'g'), `"${field}":"[REDACTED]"`),
|
|
186
|
+
json || '',
|
|
187
|
+
);
|
|
188
|
+
const originalJSON = redact(JSON.stringify(config.input));
|
|
189
|
+
const preparedJSON = redact(JSON.stringify(preparedInput));
|
|
190
|
+
if (originalJSON !== preparedJSON) {
|
|
191
|
+
console.debug(
|
|
192
|
+
'process: prepareInput changed input of type',
|
|
193
|
+
config.input?.constructor?.name,
|
|
194
|
+
'to type',
|
|
195
|
+
preparedInput?.constructor?.name,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// JSON.stringify can fail on circular references — ignore
|
|
163
200
|
}
|
|
164
|
-
}
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
config.input = preparedInput;
|
|
166
204
|
}
|
|
167
205
|
|
|
168
|
-
// Get DB object
|
|
206
|
+
// Get DB object for rights checking — lean query to avoid recursive process() call.
|
|
207
|
+
// Using lean preserves ALL fields (including createdBy) which is needed for
|
|
208
|
+
// S_CREATOR and S_SELF checks. The full process() pipeline would remove
|
|
209
|
+
// restricted fields, potentially breaking these checks.
|
|
169
210
|
if (config.dbObject && config.checkRights && this.checkRights) {
|
|
170
211
|
if (typeof config.dbObject === 'string' || config.dbObject instanceof Types.ObjectId) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
212
|
+
if (this.mainDbModel) {
|
|
213
|
+
const rawDoc = await this.mainDbModel.findById(getStringIds(config.dbObject)).lean().exec();
|
|
214
|
+
if (rawDoc) {
|
|
215
|
+
config.dbObject = (this.mainModelConstructor as any)?.map
|
|
216
|
+
? (this.mainModelConstructor as any).map(rawDoc)
|
|
217
|
+
: rawDoc;
|
|
218
|
+
}
|
|
174
219
|
}
|
|
175
220
|
}
|
|
176
221
|
}
|
|
@@ -198,21 +243,27 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
198
243
|
(config.input as Record<string, any>).updatedBy = config.currentUser.id;
|
|
199
244
|
}
|
|
200
245
|
|
|
201
|
-
// Run service function
|
|
202
|
-
// When force is enabled, bypass the Mongoose role guard plugin
|
|
246
|
+
// Run service function with incremented depth
|
|
247
|
+
// When force is enabled, also bypass the Mongoose role guard plugin
|
|
248
|
+
const executeServiceFunc = () => RequestContext.runWithIncrementedProcessDepth(() => serviceFunc(config));
|
|
249
|
+
|
|
203
250
|
let result = config.force
|
|
204
|
-
? await RequestContext.runWithBypassRoleGuard(
|
|
205
|
-
: await
|
|
251
|
+
? await RequestContext.runWithBypassRoleGuard(executeServiceFunc)
|
|
252
|
+
: await executeServiceFunc();
|
|
206
253
|
|
|
207
254
|
// Pop and map main model
|
|
255
|
+
// Skip on nested calls UNLESS populate was explicitly requested —
|
|
256
|
+
// the outermost call handles population for the final response.
|
|
208
257
|
if (config.processFieldSelection && config.fieldSelection && this.processFieldSelection) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
258
|
+
if (!isNested || config.populate) {
|
|
259
|
+
let temps = result;
|
|
260
|
+
if (!Array.isArray(result)) {
|
|
261
|
+
temps = [result];
|
|
262
|
+
}
|
|
263
|
+
for (const temp of temps) {
|
|
264
|
+
const field = config.outputPath ? _.get(temp, config.outputPath) : temp;
|
|
265
|
+
await this.processFieldSelection(field, config.fieldSelection, config.processFieldSelection);
|
|
266
|
+
}
|
|
216
267
|
}
|
|
217
268
|
}
|
|
218
269
|
|
|
@@ -222,6 +273,14 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
222
273
|
if (!opts.targetModel && config.outputType) {
|
|
223
274
|
opts.targetModel = config.outputType;
|
|
224
275
|
}
|
|
276
|
+
|
|
277
|
+
// On nested calls without explicit populate: skip model mapping
|
|
278
|
+
// (the outermost call and CheckSecurityInterceptor handle final mapping).
|
|
279
|
+
// Secret removal (removeSecrets) stays active at ALL depths.
|
|
280
|
+
if (isNested && !config.populate && typeof opts === 'object') {
|
|
281
|
+
opts.targetModel = undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
225
284
|
if (config.outputPath) {
|
|
226
285
|
let temps = result;
|
|
227
286
|
if (!Array.isArray(result)) {
|
|
@@ -236,7 +295,9 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
236
295
|
}
|
|
237
296
|
|
|
238
297
|
// Check output rights
|
|
239
|
-
|
|
298
|
+
// Skip on nested calls — the outermost process() and CheckSecurityInterceptor
|
|
299
|
+
// perform the final output rights check on the complete response.
|
|
300
|
+
if (!isNested && config.checkRights && (await this.checkRights(undefined, config.currentUser as any, config))) {
|
|
240
301
|
const opts: any = {
|
|
241
302
|
dbObject: config.dbObject,
|
|
242
303
|
processType: ProcessType.OUTPUT,
|
|
@@ -362,7 +423,7 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
362
423
|
data: any,
|
|
363
424
|
fieldsSelection: FieldSelection,
|
|
364
425
|
options: {
|
|
365
|
-
dbModel?: Model<Document & T>;
|
|
426
|
+
dbModel?: Model<Document & T> | SafeModel<Document & T>;
|
|
366
427
|
ignoreSelections?: boolean;
|
|
367
428
|
model?: new (...args: any[]) => T;
|
|
368
429
|
} = {},
|
|
@@ -372,7 +433,7 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
372
433
|
model: this.mainModelConstructor,
|
|
373
434
|
...options,
|
|
374
435
|
};
|
|
375
|
-
return popAndMap(data, fieldsSelection, config.model, config.dbModel
|
|
436
|
+
return popAndMap(data, fieldsSelection, config.model, config.dbModel as Model<Document & T>, {
|
|
376
437
|
ignoreSelections: config.ignoreSelections,
|
|
377
438
|
});
|
|
378
439
|
}
|
|
@@ -19,6 +19,13 @@ export interface IRequestContext {
|
|
|
19
19
|
tenantRole?: string;
|
|
20
20
|
/** When true, indicates admin bypass is active (admin without header sees all data) */
|
|
21
21
|
isAdminBypass?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Tracks the nesting depth of process() calls.
|
|
24
|
+
* 0 = outermost call (full pipeline), > 0 = nested call (reduced pipeline).
|
|
25
|
+
* Used to skip redundant populate, output mapping, and output rights checks
|
|
26
|
+
* on inner calls — the outermost call and CheckSecurityInterceptor handle these.
|
|
27
|
+
*/
|
|
28
|
+
processDepth?: number;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
@@ -81,6 +88,46 @@ export class RequestContext {
|
|
|
81
88
|
return this.storage.run(context, fn);
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Get the current process() nesting depth.
|
|
93
|
+
* Returns 0 if not inside a process() call.
|
|
94
|
+
*/
|
|
95
|
+
static getProcessDepth(): number {
|
|
96
|
+
return this.storage.getStore()?.processDepth || 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run a function with incremented process depth.
|
|
101
|
+
*
|
|
102
|
+
* Used internally by `ModuleService.process()` to wrap the serviceFunc call.
|
|
103
|
+
* At depth > 0, the built-in pipeline skips redundant populate, model mapping,
|
|
104
|
+
* and output rights checks — the outermost call (depth 0) and
|
|
105
|
+
* CheckSecurityInterceptor handle these for the final response.
|
|
106
|
+
*
|
|
107
|
+
* Also available for custom pipeline implementations that wrap `process()`.
|
|
108
|
+
*
|
|
109
|
+
* **Security contract:** Code running at depth > 0 must NOT return data
|
|
110
|
+
* directly to external consumers without an outer depth-0 `process()` call
|
|
111
|
+
* or manual `checkRights` — the output rights check is skipped at depth > 0.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```typescript
|
|
115
|
+
* // Custom pipeline that wraps a service call with depth tracking
|
|
116
|
+
* const result = await RequestContext.runWithIncrementedProcessDepth(async () => {
|
|
117
|
+
* return this.innerService.create(input, serviceOptions);
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
static runWithIncrementedProcessDepth<T>(fn: () => T): T {
|
|
122
|
+
const currentStore = this.storage.getStore();
|
|
123
|
+
const currentDepth = currentStore?.processDepth || 0;
|
|
124
|
+
const context: IRequestContext = {
|
|
125
|
+
...currentStore,
|
|
126
|
+
processDepth: currentDepth + 1,
|
|
127
|
+
};
|
|
128
|
+
return this.storage.run(context, fn);
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
static getTenantId(): string | undefined {
|
|
85
132
|
return this.storage.getStore()?.tenantId;
|
|
86
133
|
}
|