@lenne.tech/nest-server 11.19.0 → 11.20.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/dist/core/common/helpers/db.helper.d.ts +1 -1
- package/dist/core/common/helpers/db.helper.js +10 -4
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -1
- package/dist/core/common/helpers/input.helper.js +6 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +8 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- 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.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/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +11 -12
- package/src/core/common/helpers/db.helper.ts +13 -6
- package/src/core/common/helpers/input.helper.ts +6 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +8 -0
- package/src/core/common/interfaces/server-options.interface.ts +80 -0
- 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.module.ts +5 -0
- package/src/index.ts +1 -0
|
@@ -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
|
}
|
package/src/core.module.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
|
|
|
20
20
|
import { mongooseAuditFieldsPlugin } from './core/common/plugins/mongoose-audit-fields.plugin';
|
|
21
21
|
import { mongoosePasswordPlugin } from './core/common/plugins/mongoose-password.plugin';
|
|
22
22
|
import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-guard.plugin';
|
|
23
|
+
import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
|
|
23
24
|
import { ConfigService } from './core/common/services/config.service';
|
|
24
25
|
import { EmailService } from './core/common/services/email.service';
|
|
25
26
|
import { MailjetService } from './core/common/services/mailjet.service';
|
|
@@ -214,6 +215,10 @@ export class CoreModule implements NestModule {
|
|
|
214
215
|
if (config.security?.mongooseAuditFieldsPlugin !== false) {
|
|
215
216
|
connection.plugin(mongooseAuditFieldsPlugin);
|
|
216
217
|
}
|
|
218
|
+
// Add tenant isolation plugin (opt-in via multiTenancy config)
|
|
219
|
+
if (config.multiTenancy && config.multiTenancy.enabled !== false) {
|
|
220
|
+
connection.plugin(mongooseTenantPlugin);
|
|
221
|
+
}
|
|
217
222
|
return connection;
|
|
218
223
|
};
|
|
219
224
|
|
package/src/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
|
|
|
75
75
|
export * from './core/common/plugins/mongoose-audit-fields.plugin';
|
|
76
76
|
export * from './core/common/plugins/mongoose-password.plugin';
|
|
77
77
|
export * from './core/common/plugins/mongoose-role-guard.plugin';
|
|
78
|
+
export * from './core/common/plugins/mongoose-tenant.plugin';
|
|
78
79
|
export * from './core/common/scalars/any.scalar';
|
|
79
80
|
export * from './core/common/scalars/date-timestamp.scalar';
|
|
80
81
|
export * from './core/common/scalars/date.scalar';
|