@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.
Files changed (30) hide show
  1. package/.claude/rules/configurable-features.md +1 -0
  2. package/.claude/rules/framework-compatibility.md +79 -0
  3. package/CLAUDE.md +60 -0
  4. package/FRAMEWORK-API.md +235 -0
  5. package/dist/core/common/decorators/restricted.decorator.js +21 -4
  6. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  8. package/dist/core/common/services/crud.service.d.ts +4 -1
  9. package/dist/core/common/services/crud.service.js +24 -2
  10. package/dist/core/common/services/crud.service.js.map +1 -1
  11. package/dist/core/common/services/module.service.d.ts +3 -2
  12. package/dist/core/common/services/module.service.js +43 -20
  13. package/dist/core/common/services/module.service.js.map +1 -1
  14. package/dist/core/common/services/request-context.service.d.ts +3 -0
  15. package/dist/core/common/services/request-context.service.js +12 -0
  16. package/dist/core/common/services/request-context.service.js.map +1 -1
  17. package/dist/server/modules/file/file-info.model.d.ts +1 -5
  18. package/dist/server/modules/user/user.model.d.ts +1 -5
  19. package/dist/tsconfig.build.tsbuildinfo +1 -1
  20. package/docs/REQUEST-LIFECYCLE.md +25 -2
  21. package/docs/native-driver-security.md +153 -0
  22. package/docs/process-performance-optimization.md +493 -0
  23. package/migration-guides/11.22.0-to-11.22.1.md +105 -0
  24. package/migration-guides/11.22.x-to-11.23.0.md +235 -0
  25. package/package.json +33 -31
  26. package/src/core/common/decorators/restricted.decorator.ts +44 -4
  27. package/src/core/common/interfaces/server-options.interface.ts +8 -0
  28. package/src/core/common/services/crud.service.ts +77 -5
  29. package/src/core/common/services/module.service.ts +96 -35
  30. 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({ ...data.input, createdBy: currentUserId, updatedBy: currentUserId }).save();
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.raw = true;
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: Model<Document & T>;
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
- new Promise(() => {
153
- if (
154
- inputJSON?.replace(/"password":\s*"[^"]*"/, '') !==
155
- JSON.stringify(preparedInput)?.replace(/"password":\s*"[^"]*"/, '')
156
- ) {
157
- console.debug(
158
- 'CheckSecurityInterceptor: securityCheck changed input of type',
159
- originalInput.constructor.name,
160
- 'to type',
161
- preparedInput.constructor.name,
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
- const dbObject = await this.get(getStringIds(config.dbObject));
172
- if (dbObject) {
173
- config.dbObject = dbObject;
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 as well
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(() => serviceFunc(config))
205
- : await serviceFunc(config);
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
- let temps = result;
210
- if (!Array.isArray(result)) {
211
- temps = [result];
212
- }
213
- for (const temp of temps) {
214
- const field = config.outputPath ? _.get(temp, config.outputPath) : temp;
215
- await this.processFieldSelection(field, config.fieldSelection, config.processFieldSelection);
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
- if (config.checkRights && (await this.checkRights(undefined, config.currentUser as any, config))) {
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
  }