@lenne.tech/nest-server 11.22.1 → 11.23.1
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 +77 -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
|
@@ -863,6 +863,28 @@ The `process()` method in `ModuleService` is the **primary** way to handle CRUD
|
|
|
863
863
|
+---------------------------------------------------------------+
|
|
864
864
|
```
|
|
865
865
|
|
|
866
|
+
### Depth-Based Optimization (v11.23.0+)
|
|
867
|
+
|
|
868
|
+
When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
|
|
869
|
+
|
|
870
|
+
| Step | Depth 0 (outermost) | Depth > 0 (nested) |
|
|
871
|
+
|------|---------------------|---------------------|
|
|
872
|
+
| 1. prepareInput | Runs | Runs |
|
|
873
|
+
| 2. checkRights (INPUT) | Runs | Runs |
|
|
874
|
+
| 3. serviceFunc | Runs | Runs |
|
|
875
|
+
| 4. processFieldSelection | Runs | **Skipped** (unless `populate` explicitly set) |
|
|
876
|
+
| 5. prepareOutput (model mapping) | Runs | **Skipped** (secret removal still active) |
|
|
877
|
+
| 6. checkRights (OUTPUT) | Runs | **Skipped** |
|
|
878
|
+
|
|
879
|
+
**Security is maintained** because:
|
|
880
|
+
1. Input authorization (step 2) always runs at every depth
|
|
881
|
+
2. Output authorization (step 6) runs at the outermost call
|
|
882
|
+
3. `CheckSecurityInterceptor` (Safety Net) runs on the final HTTP response
|
|
883
|
+
|
|
884
|
+
**Important:** Code running at depth > 0 (cron jobs, queue consumers, event handlers outside the HTTP cycle) must NOT return data directly to external consumers without either an outer depth-0 `process()` call or manual `checkRights` — the output rights check is skipped at depth > 0.
|
|
885
|
+
|
|
886
|
+
See [process() Performance Optimization](process-performance-optimization.md) for details.
|
|
887
|
+
|
|
866
888
|
### Key Options
|
|
867
889
|
|
|
868
890
|
| Option | Type | Default | Effect |
|
|
@@ -870,8 +892,9 @@ The `process()` method in `ModuleService` is the **primary** way to handle CRUD
|
|
|
870
892
|
| `force` | boolean | `false` | Disables checkRights, checkRoles, removeSecrets, bypasses role guard plugin |
|
|
871
893
|
| `raw` | boolean | `false` | Disables prepareInput and prepareOutput entirely |
|
|
872
894
|
| `checkRights` | boolean | `true` | Enable/disable authorization checks |
|
|
873
|
-
| `populate` | object | - | Field selection for population |
|
|
895
|
+
| `populate` | object | - | Field selection for population (overrides nested skip) |
|
|
874
896
|
| `currentUser` | object | from request | Override the current user |
|
|
897
|
+
| `debugProcessInput` | boolean | `false` | Config flag: log when prepareInput changes the input type (performance cost) |
|
|
875
898
|
|
|
876
899
|
### Alternative: processResult()
|
|
877
900
|
|
|
@@ -883,7 +906,7 @@ const doc = await this.mainDbModel.findById(id).exec();
|
|
|
883
906
|
return this.processResult(doc, serviceOptions);
|
|
884
907
|
```
|
|
885
908
|
|
|
886
|
-
`processResult()` handles population and `prepareOutput()` only. Security is handled by the Safety Net (Mongoose plugins for input, interceptors for output).
|
|
909
|
+
`processResult()` handles population and `prepareOutput()` only. It does **not** perform authorization checks (`checkRights`). Security is handled by the Safety Net (Mongoose plugins for input, interceptors for output). If called outside an HTTP request cycle (cron, queue), call `checkRights` manually before returning data to external consumers.
|
|
887
910
|
|
|
888
911
|
---
|
|
889
912
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Native MongoDB Driver Access — Security Policy
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Mongoose registers plugins (Tenant isolation, Audit fields, RoleGuard, Password hashing, ID transformation) as pre/post hooks on schema operations. These hooks **only** fire on Mongoose Model methods — not on the native MongoDB driver.
|
|
6
|
+
|
|
7
|
+
Three access paths bypass all plugins:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// Path 1: model.collection.* (native driver via Mongoose Model)
|
|
11
|
+
await this.userModel.collection.insertOne(doc); // No tenantId, no audit, no password hash
|
|
12
|
+
|
|
13
|
+
// Path 2: model.db (Mongoose Connection → native Db → native MongoClient)
|
|
14
|
+
await this.userModel.db.db.collection('users').insertOne(doc); // Same problem
|
|
15
|
+
|
|
16
|
+
// Path 3: connection.db.collection() (native driver via injected Connection)
|
|
17
|
+
await this.connection.db.collection('users').insertOne(doc); // Same problem
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### What Gets Bypassed
|
|
21
|
+
|
|
22
|
+
| Plugin | Function | Risk When Bypassed |
|
|
23
|
+
|--------|---------|---------------------|
|
|
24
|
+
| **Tenant Plugin** | Sets tenantId on new documents, filters queries | Data leak between tenants |
|
|
25
|
+
| **Audit Plugin** | Sets createdBy/updatedBy | No traceability |
|
|
26
|
+
| **RoleGuard Plugin** | Prevents role escalation | Privilege escalation |
|
|
27
|
+
| **Password Plugin** | Hashes passwords (bcrypt) | Plaintext passwords in DB |
|
|
28
|
+
| **ID Plugin** | Transforms _id to id | Inconsistent API responses |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Protection: Three Layers
|
|
33
|
+
|
|
34
|
+
### Layer 1: TypeScript Type Guard in CrudService
|
|
35
|
+
|
|
36
|
+
`mainDbModel` is blocked via `Omit<Model, 'collection' | 'db'>` type (`SafeModel`):
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
this.mainDbModel.find(...) // Works
|
|
40
|
+
this.mainDbModel.insertMany(...) // Works
|
|
41
|
+
this.mainDbModel.collection // TypeScript error
|
|
42
|
+
this.mainDbModel.db // TypeScript error
|
|
43
|
+
|
|
44
|
+
this.getNativeCollection('reason') // Escape hatch for native Collection, with logging
|
|
45
|
+
this.getNativeConnection('reason') // Escape hatch for Mongoose Connection, with logging
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**`getNativeCollection(reason)`** and **`getNativeConnection(reason)`** are the only ways to access the native driver from CrudService-based services. They:
|
|
49
|
+
- Require a reason (string parameter)
|
|
50
|
+
- Log a `[SECURITY]` warning on every access
|
|
51
|
+
- Throw an error if no reason is provided
|
|
52
|
+
|
|
53
|
+
### Layer 2: CLAUDE.md Rules
|
|
54
|
+
|
|
55
|
+
Documented in `nest-server/CLAUDE.md`, `nest-server-starter/CLAUDE.md`, and `lt-monorepo/CLAUDE.md`:
|
|
56
|
+
- `model.collection.*` is forbidden
|
|
57
|
+
- `connection.db.collection()` only for schema-less collections
|
|
58
|
+
- Mongoose Model methods as alternatives
|
|
59
|
+
|
|
60
|
+
### Layer 3: AI Review Rules
|
|
61
|
+
|
|
62
|
+
Review agents (backend-reviewer, security-reviewer, code-reviewer) check on every review:
|
|
63
|
+
- `.collection.` access on Mongoose Models → Security risk (HIGH)
|
|
64
|
+
- `.db.collection()` on tenant-scoped collections → Security risk
|
|
65
|
+
- `.db.collection()` on schema-less collections → Allowed
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Secure Alternatives
|
|
70
|
+
|
|
71
|
+
| Forbidden (native driver) | Allowed (Mongoose — plugins active) |
|
|
72
|
+
|--------------------------|-------------------------------------|
|
|
73
|
+
| `collection.insertOne(doc)` | `Model.insertMany([doc])` |
|
|
74
|
+
| `collection.bulkWrite(ops)` | `Model.bulkWrite(ops)` |
|
|
75
|
+
| `collection.updateOne(f, u)` | `Model.updateOne(f, u)` |
|
|
76
|
+
| `collection.updateMany(f, u)` | `Model.updateMany(f, u)` |
|
|
77
|
+
| `collection.deleteOne(f)` | `Model.deleteOne(f)` |
|
|
78
|
+
| `collection.deleteMany(f)` | `Model.deleteMany(f)` |
|
|
79
|
+
| `collection.find(f)` | `Model.find(f)` or `Model.find(f).lean()` |
|
|
80
|
+
| `collection.findOne(f)` | `Model.findOne(f)` or `Model.findOne(f).lean()` |
|
|
81
|
+
| `collection.aggregate(p)` | `Model.aggregate(p)` |
|
|
82
|
+
| `collection.countDocuments(f)` | `Model.countDocuments(f)` |
|
|
83
|
+
|
|
84
|
+
**Performance:** Mongoose Model methods have minimal overhead compared to the native driver — the plugins themselves cost < 0.1ms per call (see `docs/process-performance-optimization.md`).
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Allowed Cases for connection.db.collection()
|
|
89
|
+
|
|
90
|
+
| Use Case | Example | Why Allowed |
|
|
91
|
+
|----------|---------|-------------|
|
|
92
|
+
| Schema-less collections | `db.collection('mcp_oauth_clients')` | No Mongoose schema, no tenantId field |
|
|
93
|
+
| BetterAuth tables | `db.collection('session')`, `db.collection('account')` | IAM infrastructure, not tenant-scoped |
|
|
94
|
+
| Read-only aggregations | `db.collection('incidents').countDocuments({tenantId, ...})` | Read-only, manual tenant filter |
|
|
95
|
+
| Admin operations | `db.collection('users').createIndex(...)` | Index management, no CRUD |
|
|
96
|
+
| DevOps/Backup | `db.collection(name).drop()` | One-time admin actions |
|
|
97
|
+
|
|
98
|
+
### Not Allowed
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// Write on tenant-scoped collection without Mongoose
|
|
102
|
+
await this.connection.db.collection('orders').insertOne({ ... });
|
|
103
|
+
// → await this.orderModel.insertMany([{ ... }]);
|
|
104
|
+
|
|
105
|
+
// Read without tenant filter on tenant-scoped data
|
|
106
|
+
await this.connection.db.collection('orders').find({}).toArray();
|
|
107
|
+
// → await this.orderModel.find({}); // Tenant plugin filters automatically
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## getNativeCollection() / getNativeConnection() Reference
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// Definitions in CrudService:
|
|
116
|
+
protected getNativeCollection(reason: string): Collection
|
|
117
|
+
protected getNativeConnection(reason: string): Connection
|
|
118
|
+
|
|
119
|
+
// Usage — native Collection:
|
|
120
|
+
const col = this.getNativeCollection('Migration: Bulk import of historical data without tenant context');
|
|
121
|
+
await col.insertOne(doc);
|
|
122
|
+
|
|
123
|
+
// Usage — native Db (for cross-collection reads, schema-less collections):
|
|
124
|
+
const conn = this.getNativeConnection('Statistics: count chatmessages across all tenants');
|
|
125
|
+
const count = await conn.db.collection('chatmessages').countDocuments({ ... });
|
|
126
|
+
|
|
127
|
+
// Without reason → Error:
|
|
128
|
+
this.getNativeCollection(''); // throws Error
|
|
129
|
+
this.getNativeConnection(''); // throws Error
|
|
130
|
+
|
|
131
|
+
// Logging:
|
|
132
|
+
// [SECURITY] Native collection access: Migration: Bulk import... (Model: MonitorCheck)
|
|
133
|
+
// [SECURITY] Native db access: Statistics: count chatmessages... (Model: Statistics)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Review Checklist
|
|
139
|
+
|
|
140
|
+
When reviewing code (manual or AI), check for `.collection.` and `.db.collection(`:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
1. Is it model.collection.* ?
|
|
144
|
+
→ ALWAYS a security risk. Use getNativeCollection() or Model method.
|
|
145
|
+
|
|
146
|
+
2. Is it connection.db.collection('name') ?
|
|
147
|
+
→ Does the collection have a Mongoose schema with tenantId?
|
|
148
|
+
YES → Security violation. Use Mongoose Model.
|
|
149
|
+
NO → Continue to 3.
|
|
150
|
+
→ Are data being written?
|
|
151
|
+
YES → Verify tenantId/audit is set manually.
|
|
152
|
+
NO → Allowed (read-only).
|
|
153
|
+
```
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# nest-server: process() Performance-Optimierung
|
|
2
|
+
|
|
3
|
+
## Kontext
|
|
4
|
+
|
|
5
|
+
Die `process()` Pipeline in `ModuleService` ist der zentrale Verarbeitungspfad fuer alle CRUD-Operationen. Bei hochfrequenten oder verschachtelten Aufrufen (Service-Kaskaden) entsteht unnoetig hoher Memory- und CPU-Verbrauch. Die folgenden Optimierungen reduzieren den Overhead ohne die Sicherheit herabzusetzen.
|
|
6
|
+
|
|
7
|
+
**Analyse-Basis:** 8 Kundenprojekte + 13 lenne.tech-Projekte geprueft — alle Aenderungen sind rueckwaertskompatibel.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Aenderung 1: JSON.stringify Debug konfigurierbar machen
|
|
12
|
+
|
|
13
|
+
### Datei: `src/core/common/services/module.service.ts`
|
|
14
|
+
|
|
15
|
+
### Problem
|
|
16
|
+
|
|
17
|
+
Zeilen 149-164 serialisieren den Input bei **jedem** process()-Aufruf zweimal (vorher/nachher) — nur fuer einen `console.debug` Vergleich. Das `new Promise(() => ...)` ist ein Fire-and-Forget ohne await.
|
|
18
|
+
|
|
19
|
+
### Aktueller Code (Zeile 149-165)
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
const originalInput = config.input;
|
|
23
|
+
const inputJSON = JSON.stringify(originalInput);
|
|
24
|
+
const preparedInput = await this.prepareInput(config.input, config);
|
|
25
|
+
new Promise(() => {
|
|
26
|
+
if (
|
|
27
|
+
inputJSON?.replace(/"password":\s*"[^"]*"/, '') !==
|
|
28
|
+
JSON.stringify(preparedInput)?.replace(/"password":\s*"[^"]*"/, '')
|
|
29
|
+
) {
|
|
30
|
+
console.debug(
|
|
31
|
+
'CheckSecurityInterceptor: securityCheck changed input of type',
|
|
32
|
+
originalInput.constructor.name,
|
|
33
|
+
'to type',
|
|
34
|
+
preparedInput.constructor.name,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
config.input = preparedInput;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Neuer Code
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
const preparedInput = await this.prepareInput(config.input, config);
|
|
45
|
+
|
|
46
|
+
// Debug-Vergleich nur wenn explizit konfiguriert (default: false)
|
|
47
|
+
if (this.configService?.getFastButReadOnly('debugProcessInput', false)) {
|
|
48
|
+
try {
|
|
49
|
+
const originalJSON = JSON.stringify(config.input)?.replace(/"password":\s*"[^"]*"/, '');
|
|
50
|
+
const preparedJSON = JSON.stringify(preparedInput)?.replace(/"password":\s*"[^"]*"/, '');
|
|
51
|
+
if (originalJSON !== preparedJSON) {
|
|
52
|
+
console.debug(
|
|
53
|
+
'process: prepareInput changed input of type',
|
|
54
|
+
config.input?.constructor?.name,
|
|
55
|
+
'to type',
|
|
56
|
+
preparedInput?.constructor?.name,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// JSON.stringify kann bei zirkulaeren Referenzen fehlschlagen — ignorieren
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
config.input = preparedInput;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Datei: `src/core/common/interfaces/server-options.interface.ts`
|
|
68
|
+
|
|
69
|
+
In das `IServerOptions`-Interface (oder dem passenden Config-Bereich) hinzufuegen:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
/**
|
|
73
|
+
* When true, logs a debug message when prepareInput() changes the input type.
|
|
74
|
+
* Default: false. Enable only for debugging — has performance cost due to JSON.stringify.
|
|
75
|
+
*/
|
|
76
|
+
debugProcessInput?: boolean;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Sicherheit
|
|
80
|
+
|
|
81
|
+
Nicht betroffen — rein diagnostisch.
|
|
82
|
+
|
|
83
|
+
### Kompatibilitaet
|
|
84
|
+
|
|
85
|
+
Default `false` — kein bestehendes Projekt aendert sein Verhalten.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Aenderung 2: this.get(dbObject) durch Lean Query ersetzen
|
|
90
|
+
|
|
91
|
+
### Datei: `src/core/common/services/module.service.ts`
|
|
92
|
+
|
|
93
|
+
### Problem
|
|
94
|
+
|
|
95
|
+
Zeilen 168-176 rufen `this.get()` auf, was **rekursiv** die gesamte process()-Pipeline durchlaeuft — inklusive prepareInput, checkRights, processFieldSelection, prepareOutput. Das ist unnoetig, weil das dbObject nur als Kontext fuer den Rights-Check gebraucht wird.
|
|
96
|
+
|
|
97
|
+
Zusaetzlich ist es sogar **kontraproduktiv**: `this.get()` ohne `force` kann Felder wie `createdBy` entfernen (wegen `@Restricted(RoleEnum.ADMIN)`), die dann fuer den `S_CREATOR`-Check fehlen.
|
|
98
|
+
|
|
99
|
+
### Aktueller Code (Zeile 168-176)
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Get DB object
|
|
103
|
+
if (config.dbObject && config.checkRights && this.checkRights) {
|
|
104
|
+
if (typeof config.dbObject === 'string' || config.dbObject instanceof Types.ObjectId) {
|
|
105
|
+
const dbObject = await this.get(getStringIds(config.dbObject));
|
|
106
|
+
if (dbObject) {
|
|
107
|
+
config.dbObject = dbObject;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Neuer Code
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// Get DB object for rights checking — lean query to avoid recursive process() call.
|
|
117
|
+
// Using lean preserves ALL fields (including createdBy) which is needed for
|
|
118
|
+
// S_CREATOR and S_SELF checks. The full process() pipeline would remove
|
|
119
|
+
// restricted fields, potentially breaking these checks.
|
|
120
|
+
if (config.dbObject && config.checkRights && this.checkRights) {
|
|
121
|
+
if (typeof config.dbObject === 'string' || config.dbObject instanceof Types.ObjectId) {
|
|
122
|
+
if (this.mainDbModel) {
|
|
123
|
+
const rawDoc = await this.mainDbModel.findById(getStringIds(config.dbObject)).lean().exec();
|
|
124
|
+
if (rawDoc) {
|
|
125
|
+
// Map to Model instance so securityCheck() is available as a method
|
|
126
|
+
config.dbObject = (this.mainModelConstructor as any)?.map
|
|
127
|
+
? (this.mainModelConstructor as any).map(rawDoc)
|
|
128
|
+
: rawDoc;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Sicherheit
|
|
136
|
+
|
|
137
|
+
**Verbessert** — lean Query behaelt alle Felder (z.B. `createdBy`), was den `S_CREATOR`-Check zuverlaessiger macht als der aktuelle Code.
|
|
138
|
+
|
|
139
|
+
### Nachfolgende Prozesse geprueft
|
|
140
|
+
|
|
141
|
+
- `checkRights(input)` (Zeile 179-184): Verwendet `config.dbObject` fuer `S_CREATOR`/`S_SELF`/`memberOf` Checks via `equalIds()` — funktioniert mit lean+map, da `equalIds` sowohl ObjectIds als auch Strings vergleicht.
|
|
142
|
+
- `checkRights(output)` (Zeile 239-250): Gleiche Nutzung — funktioniert.
|
|
143
|
+
- `validateRestricted()` in `checkRestricted()` (restricted.decorator.ts, Zeile 172-174): Prueft `'createdBy' in data && equalIds(data.createdBy, user)` — lean-Objekt hat `createdBy` immer (nicht entfernt durch Pipeline).
|
|
144
|
+
- `memberOf`-Check (Zeile 196-218): Liest `config.dbObject?.[property]` — lean-Objekt hat alle Properties.
|
|
145
|
+
|
|
146
|
+
### Kompatibilitaet
|
|
147
|
+
|
|
148
|
+
- `CrudService.update()` (Zeile 552): Setzt `dbObject` bereits als lean-Objekt (`findById().lean()`) → konsistent.
|
|
149
|
+
- Kein Projekt uebergibt ein Mongoose-Document als dbObject → kein Konflikt.
|
|
150
|
+
- Services die `process()` direkt aufrufen mit `dbObject` als String (z.B. swaktiv/OfferService): Profitieren von der Optimierung.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Aenderung 3: Depth-Tracking in RequestContext
|
|
155
|
+
|
|
156
|
+
### Datei: `src/core/common/services/request-context.service.ts`
|
|
157
|
+
|
|
158
|
+
### Problem
|
|
159
|
+
|
|
160
|
+
Bei Service-Kaskaden (A.create → B.create → C.create) laeuft die volle process()-Pipeline auf jeder Ebene. Populate, prepareOutput-Mapping und Output-checkRights auf inneren Ebenen sind unnoetig, weil die aeussere Ebene und der CheckSecurityInterceptor diese Aufgaben uebernehmen.
|
|
161
|
+
|
|
162
|
+
### Aenderung am Interface
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
export interface IRequestContext {
|
|
166
|
+
// ... bestehende Felder ...
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Tracks the nesting depth of process() calls.
|
|
170
|
+
* 0 = outermost call (full pipeline), > 0 = nested call (reduced pipeline).
|
|
171
|
+
* Used to skip redundant populate, output mapping, and output rights checks
|
|
172
|
+
* on inner calls — the outermost call and CheckSecurityInterceptor handle these.
|
|
173
|
+
*/
|
|
174
|
+
processDepth?: number;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Neue Methoden
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
/**
|
|
182
|
+
* Get the current process() nesting depth.
|
|
183
|
+
* Returns 0 if not inside a process() call.
|
|
184
|
+
*/
|
|
185
|
+
static getProcessDepth(): number {
|
|
186
|
+
return this.storage.getStore()?.processDepth || 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run a function with incremented process depth.
|
|
191
|
+
* Skips context creation if already at depth > 0 to avoid redundant object spread.
|
|
192
|
+
*/
|
|
193
|
+
static runWithIncrementedProcessDepth<T>(fn: () => T): T {
|
|
194
|
+
const currentStore = this.storage.getStore();
|
|
195
|
+
const currentDepth = currentStore?.processDepth || 0;
|
|
196
|
+
const context: IRequestContext = {
|
|
197
|
+
...currentStore,
|
|
198
|
+
processDepth: currentDepth + 1,
|
|
199
|
+
};
|
|
200
|
+
return this.storage.run(context, fn);
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Sicherheit
|
|
205
|
+
|
|
206
|
+
Nicht betroffen — das Depth-Tracking ist rein informativ und aendert keine Berechtigungen.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Aenderung 4: process() — Depth-basierte Optimierung
|
|
211
|
+
|
|
212
|
+
### Datei: `src/core/common/services/module.service.ts`
|
|
213
|
+
|
|
214
|
+
### Aenderung in process() (Zeile 81ff)
|
|
215
|
+
|
|
216
|
+
Am Anfang der Methode nach der Config-Erstellung (nach Zeile 108):
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
// Detect nested process() calls
|
|
220
|
+
const currentDepth = RequestContext.getProcessDepth();
|
|
221
|
+
const isNested = currentDepth > 0;
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### serviceFunc mit Depth-Tracking ausfuehren (Zeile 201-205 ersetzen)
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// Run service function with incremented depth
|
|
228
|
+
// When force is enabled, also bypass the Mongoose role guard plugin
|
|
229
|
+
const executeServiceFunc = () =>
|
|
230
|
+
RequestContext.runWithIncrementedProcessDepth(() => serviceFunc(config));
|
|
231
|
+
|
|
232
|
+
let result = config.force
|
|
233
|
+
? await RequestContext.runWithBypassRoleGuard(executeServiceFunc)
|
|
234
|
+
: await executeServiceFunc();
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### processFieldSelection bei inneren Calls ueberspringen (Zeile 207-217 ersetzen)
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
// Pop and map main model
|
|
241
|
+
// Skip on nested calls UNLESS populate was explicitly requested —
|
|
242
|
+
// the outermost call handles population for the final response.
|
|
243
|
+
if (config.processFieldSelection && config.fieldSelection && this.processFieldSelection) {
|
|
244
|
+
if (!isNested || config.populate) {
|
|
245
|
+
let temps = result;
|
|
246
|
+
if (!Array.isArray(result)) {
|
|
247
|
+
temps = [result];
|
|
248
|
+
}
|
|
249
|
+
for (const temp of temps) {
|
|
250
|
+
const field = config.outputPath ? _.get(temp, config.outputPath) : temp;
|
|
251
|
+
await this.processFieldSelection(field, config.fieldSelection, config.processFieldSelection);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Logik:**
|
|
258
|
+
- `isNested = false` (Depth 0, aeusserster Call): Populate laeuft immer → User bekommt vollstaendiges Ergebnis.
|
|
259
|
+
- `isNested = true` (Depth > 0, innerer Call) OHNE `config.populate`: Populate wird uebersprungen → Ergebnis wird intern weiterverarbeitet.
|
|
260
|
+
- `isNested = true` MIT `config.populate` (explizit vom Caller gesetzt): Populate laeuft → der innere Service hat das explizit angefordert weil er die Daten braucht.
|
|
261
|
+
|
|
262
|
+
### prepareOutput bei inneren Calls reduzieren (Zeile 219-236 ersetzen)
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// Prepare output
|
|
266
|
+
if (config.prepareOutput && this.prepareOutput) {
|
|
267
|
+
const opts = config.prepareOutput;
|
|
268
|
+
if (!opts.targetModel && config.outputType) {
|
|
269
|
+
opts.targetModel = config.outputType;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// On nested calls without explicit populate: skip model mapping
|
|
273
|
+
// (the outermost call and CheckSecurityInterceptor handle final mapping).
|
|
274
|
+
// Secret removal (removeSecrets) stays active at ALL depths.
|
|
275
|
+
if (isNested && !config.populate && typeof opts === 'object') {
|
|
276
|
+
opts.targetModel = undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (config.outputPath) {
|
|
280
|
+
let temps = result;
|
|
281
|
+
if (!Array.isArray(result)) {
|
|
282
|
+
temps = [result];
|
|
283
|
+
}
|
|
284
|
+
for (const temp of temps) {
|
|
285
|
+
_.set(temp, config.outputPath, await this.prepareOutput(_.get(temp, config.outputPath), opts));
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
result = await this.prepareOutput(result, config);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Output-checkRights bei inneren Calls ueberspringen (Zeile 238-250 ersetzen)
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// Check output rights
|
|
297
|
+
// Skip on nested calls — the outermost process() and CheckSecurityInterceptor
|
|
298
|
+
// perform the final output rights check on the complete response.
|
|
299
|
+
if (!isNested && config.checkRights && (await this.checkRights(undefined, config.currentUser as any, config))) {
|
|
300
|
+
const opts: any = {
|
|
301
|
+
dbObject: config.dbObject,
|
|
302
|
+
processType: ProcessType.OUTPUT,
|
|
303
|
+
roles: config.roles,
|
|
304
|
+
throwError: false,
|
|
305
|
+
};
|
|
306
|
+
if (config.outputType) {
|
|
307
|
+
opts.metatype = config.outputType;
|
|
308
|
+
}
|
|
309
|
+
result = await this.checkRights(result, config.currentUser as any, opts);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Sicherheit
|
|
314
|
+
|
|
315
|
+
**Gewaehrleistet durch drei Schichten:**
|
|
316
|
+
|
|
317
|
+
1. **Input-checkRights laeuft IMMER** (auch bei isNested) — unberechtigte Eingaben werden auf jeder Ebene abgefangen.
|
|
318
|
+
2. **Output-checkRights auf Depth 0** — der aeusserste Call filtert das finale Ergebnis.
|
|
319
|
+
3. **CheckSecurityInterceptor** — das letzte Sicherheitsnetz in der HTTP-Response-Pipeline ruft `securityCheck()` rekursiv auf dem gesamten Response-Baum auf, inklusive aller verschachtelten Objekte.
|
|
320
|
+
|
|
321
|
+
**Gegenprobe:** Was passiert wenn ein innerer Service ein Objekt zurueckgibt das der User nicht sehen darf?
|
|
322
|
+
- Der innere process() ueberspringt den Output-Rights-Check → Objekt bleibt unveraendert
|
|
323
|
+
- Der aeussere process() fuehrt den Output-Rights-Check durch → Felder werden entfernt
|
|
324
|
+
- Falls der aeussere process() es auch verpasst → CheckSecurityInterceptor entfernt die Felder
|
|
325
|
+
|
|
326
|
+
### Kompatibilitaet
|
|
327
|
+
|
|
328
|
+
Geprueft an realen Kaskaden:
|
|
329
|
+
- **CompanyService.create()** (5-Ebenen-Kaskade): Innere creates (ProfileSite, ProfileCategory, ProfileEntry, Form) brauchen kein Populate — Ergebnisse werden nur fuer IDs weiterverwendet.
|
|
330
|
+
- **ParticipationService → UserService**: UserService.create() Ergebnis wird inline weiterverwendet, nicht an User zurueckgegeben.
|
|
331
|
+
- **ShippingService → BounceService/EmailService**: Kein Populate auf inneren Ebenen noetig.
|
|
332
|
+
- **Services mit explizitem `populate:`**: Funktionieren weiterhin, da `config.populate` den isNested-Skip ueberschreibt.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Aenderung 5: checkRestricted Metadata-Caching
|
|
337
|
+
|
|
338
|
+
### Datei: `src/core/common/decorators/restricted.decorator.ts`
|
|
339
|
+
|
|
340
|
+
### Problem
|
|
341
|
+
|
|
342
|
+
`getRestricted()` (Zeile 50-58) ruft bei jedem Property-Check `Reflect.getMetadata()` auf. Bei einem Objekt mit 15 Properties und verschachtelten Objekten summieren sich hunderte Reflect-Lookups pro Request. Die Metadata aendert sich zur Laufzeit nie (Decorators sind statisch).
|
|
343
|
+
|
|
344
|
+
### Aktueller Code (Zeile 50-58)
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
export const getRestricted = (object: unknown, propertyKey?: string): RestrictedType => {
|
|
348
|
+
if (!object) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (!propertyKey) {
|
|
352
|
+
return Reflect.getMetadata(restrictedMetaKey, object);
|
|
353
|
+
}
|
|
354
|
+
return Reflect.getMetadata(restrictedMetaKey, object, propertyKey);
|
|
355
|
+
};
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Neuer Code
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
// Cache for Restricted metadata — decorators are static, metadata never changes at runtime.
|
|
362
|
+
// Map<CacheTarget, Map<propertyKey | '__class__', RestrictedType>>
|
|
363
|
+
//
|
|
364
|
+
// CacheTarget is the class constructor (for instances) or the class itself (when object IS a constructor).
|
|
365
|
+
// This distinction is critical: getRestricted(data.constructor) passes a class as `object`,
|
|
366
|
+
// and (classFunction).constructor === Function for ALL classes — so we must use the class itself.
|
|
367
|
+
const restrictedMetadataCache = new Map<unknown, Map<string, RestrictedType>>();
|
|
368
|
+
|
|
369
|
+
export const getRestricted = (object: unknown, propertyKey?: string): RestrictedType => {
|
|
370
|
+
if (!object) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Determine cache target: use the class constructor for instances, the object itself for classes.
|
|
375
|
+
// When object IS a constructor (typeof === 'function'), using object.constructor would give Function
|
|
376
|
+
// for ALL classes, causing cache collisions.
|
|
377
|
+
const cacheTarget = typeof object === 'function' ? object : (object as any).constructor;
|
|
378
|
+
if (!cacheTarget) {
|
|
379
|
+
// Fallback for objects without constructor (e.g. Object.create(null))
|
|
380
|
+
return propertyKey
|
|
381
|
+
? Reflect.getMetadata(restrictedMetaKey, object, propertyKey)
|
|
382
|
+
: Reflect.getMetadata(restrictedMetaKey, object);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Cache lookup
|
|
386
|
+
let classCache = restrictedMetadataCache.get(cacheTarget);
|
|
387
|
+
if (!classCache) {
|
|
388
|
+
classCache = new Map();
|
|
389
|
+
restrictedMetadataCache.set(cacheTarget, classCache);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const cacheKey = propertyKey || '__class__';
|
|
393
|
+
if (classCache.has(cacheKey)) {
|
|
394
|
+
return classCache.get(cacheKey);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Cache miss: perform Reflect lookup and cache the result
|
|
398
|
+
const metadata = propertyKey
|
|
399
|
+
? Reflect.getMetadata(restrictedMetaKey, object, propertyKey)
|
|
400
|
+
: Reflect.getMetadata(restrictedMetaKey, object);
|
|
401
|
+
|
|
402
|
+
classCache.set(cacheKey, metadata);
|
|
403
|
+
return metadata;
|
|
404
|
+
};
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Zusaetzlich: _.uniq()-Optimierung in checkRestricted (Zeile 260)
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
// Aktuell:
|
|
411
|
+
const concatenatedRestrictions = config.mergeRoles ? _.uniq(objectRestrictions.concat(restricted)) : restricted;
|
|
412
|
+
|
|
413
|
+
// Optimiert — vermeidet Array-Allokation wenn objectRestrictions leer:
|
|
414
|
+
const concatenatedRestrictions = config.mergeRoles && objectRestrictions.length
|
|
415
|
+
? _.uniq(objectRestrictions.concat(restricted))
|
|
416
|
+
: restricted;
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Diese Zeile wird fuer **jede Property jedes Objekts** aufgerufen. Wenn `objectRestrictions` leer ist (haeufig bei Models ohne Class-Level `@Restricted`), spart das eine Array-Allokation + uniq-Berechnung pro Property.
|
|
420
|
+
|
|
421
|
+
### Sicherheit
|
|
422
|
+
|
|
423
|
+
**Nicht betroffen** — gleiche Ergebnisse, da Decorator-Metadata sich zur Laufzeit nicht aendert. Der Cache liefert exakt die gleichen Werte wie der direkte Reflect-Lookup.
|
|
424
|
+
|
|
425
|
+
### Kompatibilitaet
|
|
426
|
+
|
|
427
|
+
Transparent — kein Projekt muss angepasst werden.
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Zusammenfassung der Einsparungen
|
|
432
|
+
|
|
433
|
+
### Einzelner API-Call (Depth 0, kein Nesting):
|
|
434
|
+
|
|
435
|
+
| Aenderung | Einsparung |
|
|
436
|
+
|-----------|------------|
|
|
437
|
+
| JSON.stringify entfaellt | ~0.1-1 KB + 2x Serialisierung |
|
|
438
|
+
| Lean dbObject statt this.get() | ~5-15 KB + 1 rekursiver process()-Durchlauf |
|
|
439
|
+
| Metadata-Cache | CPU: ~hunderte Reflect-Lookups weniger |
|
|
440
|
+
|
|
441
|
+
### Verschachtelter Call (Depth > 0):
|
|
442
|
+
|
|
443
|
+
| Schritt | Vorher | Nachher |
|
|
444
|
+
|---------|--------|---------|
|
|
445
|
+
| JSON.stringify | 2x Serialisierung | Uebersprungen |
|
|
446
|
+
| prepareInput | Laeuft | Laeuft (unveraendert) |
|
|
447
|
+
| this.get(dbObject) | Rekursives process() | Lean Query + Map |
|
|
448
|
+
| checkRights(input) | Laeuft | Laeuft (unveraendert) |
|
|
449
|
+
| serviceFunc | Laeuft | Laeuft (unveraendert) |
|
|
450
|
+
| processFieldSelection | Volle Population | Uebersprungen (ausser explizites populate) |
|
|
451
|
+
| prepareOutput | Volles Mapping | Nur Secrets-Removal |
|
|
452
|
+
| checkRights(output) | Laeuft | Uebersprungen |
|
|
453
|
+
|
|
454
|
+
**Pro verschachteltem Call: ~30-70 KB + 2-5 DB-Queries weniger.**
|
|
455
|
+
|
|
456
|
+
### Beispiel: 8-stufige Service-Kaskade (z.B. Incident-Handling):
|
|
457
|
+
|
|
458
|
+
- Vorher: 8 x ~50 KB = ~400 KB
|
|
459
|
+
- Nachher: 1 x ~50 KB (aeusserer) + 7 x ~10 KB (innere) = ~120 KB
|
|
460
|
+
- **~70% weniger Memory**
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## Betroffene Dateien
|
|
465
|
+
|
|
466
|
+
| Datei | Aenderung |
|
|
467
|
+
|-------|-----------|
|
|
468
|
+
| `src/core/common/services/module.service.ts` | Aenderungen 1, 2, 4 |
|
|
469
|
+
| `src/core/common/services/request-context.service.ts` | Aenderung 3 |
|
|
470
|
+
| `src/core/common/decorators/restricted.decorator.ts` | Aenderung 5 |
|
|
471
|
+
| `src/core/common/interfaces/server-options.interface.ts` | Config-Option `debugProcessInput` |
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Verifizierung
|
|
476
|
+
|
|
477
|
+
1. **Bestehende E2E-Tests** muessen alle gruen bleiben — die Aenderungen sind rueckwaertskompatibel
|
|
478
|
+
2. **Service-Kaskade testen**: z.B. ein create() das intern andere Services aufruft
|
|
479
|
+
3. **Populate auf aeusserster Ebene**: `{ populate: ['user', 'customer'] }` muss weiterhin funktionieren
|
|
480
|
+
4. **securityCheck()**: Sensitive Felder (password, etc.) duerfen nie in der Response auftauchen
|
|
481
|
+
5. **S_CREATOR-Check**: User der ein Objekt erstellt hat, muss es weiterhin bearbeiten koennen
|
|
482
|
+
6. **Performance**: `process.memoryUsage()` vor/nach Optimierung bei verschachtelten Calls messen
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Risiken
|
|
487
|
+
|
|
488
|
+
| Risiko | Absicherung |
|
|
489
|
+
|--------|-------------|
|
|
490
|
+
| Innerer Service braucht populate-Ergebnis | `config.populate` ueberschreibt den isNested-Skip — explizite Anforderung wird respektiert |
|
|
491
|
+
| Output-Rights-Check auf innerem Ergebnis fehlt | CheckSecurityInterceptor ist das Sicherheitsnetz auf HTTP-Ebene |
|
|
492
|
+
| Metadata-Cache wird stale | Unmoeglich — Decorator-Metadata ist zur Compile-Zeit fixiert |
|
|
493
|
+
| Lean dbObject hat andere Struktur als hydriertes | `modelConstructor.map()` stellt die erwartete Struktur her |
|