@lenne.tech/nest-server 11.22.1 → 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.md +58 -0
- package/FRAMEWORK-API.md +6 -2
- 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/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.x-to-11.23.0.md +235 -0
- package/package.json +16 -16
- 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
|
@@ -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
|
}
|