@lenne.tech/nest-server 11.16.1 → 11.18.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/dist/config.env.js +8 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
- package/dist/core/common/decorators/response-model.decorator.js +8 -0
- package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
- package/dist/core/common/helpers/db.helper.js +2 -2
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/filter.helper.js +3 -3
- package/dist/core/common/helpers/filter.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.js +2 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
- package/dist/core/common/helpers/interceptor.helper.js +84 -0
- package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
- package/dist/core/common/helpers/service.helper.d.ts +1 -0
- package/dist/core/common/helpers/service.helper.js +1 -0
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
- package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
- package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
- package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +16 -0
- package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +29 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
- package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
- package/dist/core/common/services/config.service.js +2 -2
- package/dist/core/common/services/config.service.js.map +1 -1
- package/dist/core/common/services/model-registry.service.d.ts +8 -0
- package/dist/core/common/services/model-registry.service.js +20 -0
- package/dist/core/common/services/model-registry.service.js.map +1 -0
- package/dist/core/common/services/module.service.d.ts +2 -0
- package/dist/core/common/services/module.service.js +36 -1
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +18 -0
- package/dist/core/common/services/request-context.service.js +32 -0
- package/dist/core/common/services/request-context.service.js.map +1 -0
- package/dist/core/modules/auth/guards/auth.guard.js +2 -2
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/permissions/core-permissions.controller.d.ts +13 -0
- package/dist/core/modules/permissions/core-permissions.controller.js +71 -0
- package/dist/core/modules/permissions/core-permissions.controller.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.module.d.ts +5 -0
- package/dist/core/modules/permissions/core-permissions.module.js +36 -0
- package/dist/core/modules/permissions/core-permissions.module.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.service.d.ts +34 -0
- package/dist/core/modules/permissions/core-permissions.service.js +610 -0
- package/dist/core/modules/permissions/core-permissions.service.js.map +1 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.d.ts +93 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js +3 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js.map +1 -0
- package/dist/core/modules/permissions/permissions-scanner.d.ts +25 -0
- package/dist/core/modules/permissions/permissions-scanner.js +817 -0
- package/dist/core/modules/permissions/permissions-scanner.js.map +1 -0
- package/dist/core.module.js +41 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file-info.model.d.ts +12 -12
- package/dist/server/modules/user/user.model.d.ts +33 -33
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +35 -30
- package/src/config.env.ts +8 -2
- package/src/core/common/decorators/response-model.decorator.ts +31 -0
- package/src/core/common/helpers/db.helper.ts +2 -2
- package/src/core/common/helpers/filter.helper.ts +3 -3
- package/src/core/common/helpers/input.helper.ts +2 -2
- package/src/core/common/helpers/interceptor.helper.ts +132 -0
- package/src/core/common/helpers/service.helper.ts +1 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
- package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
- package/src/core/common/interfaces/server-options.interface.ts +186 -0
- package/src/core/common/middleware/request-context.middleware.ts +25 -0
- package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
- package/src/core/common/plugins/complexity.plugin.ts +2 -2
- package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
- package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
- package/src/core/common/services/config.service.ts +2 -2
- package/src/core/common/services/model-registry.service.ts +25 -0
- package/src/core/common/services/module.service.ts +91 -1
- package/src/core/common/services/request-context.service.ts +69 -0
- package/src/core/modules/auth/guards/auth.guard.ts +2 -2
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
- package/src/core/modules/permissions/INTEGRATION-CHECKLIST.md +56 -0
- package/src/core/modules/permissions/README.md +102 -0
- package/src/core/modules/permissions/core-permissions.controller.ts +34 -0
- package/src/core/modules/permissions/core-permissions.module.ts +36 -0
- package/src/core/modules/permissions/core-permissions.service.ts +627 -0
- package/src/core/modules/permissions/interfaces/permissions.interface.ts +125 -0
- package/src/core/modules/permissions/permissions-scanner.ts +1011 -0
- package/src/core.module.ts +62 -4
- package/src/index.ts +20 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { RoleEnum } from '../enums/role.enum';
|
|
4
|
+
import { ConfigService } from '../services/config.service';
|
|
5
|
+
import { RequestContext } from '../services/request-context.service';
|
|
6
|
+
|
|
7
|
+
const logger = new Logger('mongooseRoleGuardPlugin');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mongoose plugin that prevents unauthorized users from escalating roles.
|
|
11
|
+
* Uses RequestContext (AsyncLocalStorage) to access the current user.
|
|
12
|
+
*
|
|
13
|
+
* **When are role changes allowed?**
|
|
14
|
+
* 1. No user context (system operations, seeding, CLI scripts) → allowed
|
|
15
|
+
* 2. No currentUser on request (e.g. signUp — user not logged in) → allowed
|
|
16
|
+
* 3. User has ADMIN role → allowed
|
|
17
|
+
* 4. User has one of the configured `allowedRoles` → allowed
|
|
18
|
+
* 5. `RequestContext.runWithBypassRoleGuard()` is active → allowed
|
|
19
|
+
* 6. `CrudService.process()` with `force: true` → allowed (auto-bypasses)
|
|
20
|
+
*
|
|
21
|
+
* **When are role changes blocked?**
|
|
22
|
+
* - Logged-in non-admin user without bypass → blocked
|
|
23
|
+
* - On save (new): roles set to `[]`
|
|
24
|
+
* - On save (existing): roles reverted to original
|
|
25
|
+
* - On update: roles stripped from update object
|
|
26
|
+
*
|
|
27
|
+
* **Configuration** via `security.mongooseRoleGuardPlugin`:
|
|
28
|
+
* - `true` — Only ADMIN can assign roles (default)
|
|
29
|
+
* - `{ allowedRoles: ['ORGA', 'HR_MANAGER'] }` — Additional roles that can assign roles
|
|
30
|
+
* - `false` — Plugin disabled entirely
|
|
31
|
+
*
|
|
32
|
+
* **Bypass for authorized service code** (e.g. HR system creating users with roles):
|
|
33
|
+
* ```typescript
|
|
34
|
+
* import { RequestContext } from '@lenne.tech/nest-server';
|
|
35
|
+
*
|
|
36
|
+
* // Wrap the database operation in runWithBypassRoleGuard
|
|
37
|
+
* await RequestContext.runWithBypassRoleGuard(async () => {
|
|
38
|
+
* await this.mainDbModel.create({ email, roles: ['EMPLOYEE'] });
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* // Or use CrudService.process() with force: true
|
|
42
|
+
* return this.process(
|
|
43
|
+
* async () => this.mainDbModel.findByIdAndUpdate(id, { roles }),
|
|
44
|
+
* { serviceOptions, force: true },
|
|
45
|
+
* );
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function mongooseRoleGuardPlugin(schema) {
|
|
49
|
+
// Pre-save hook
|
|
50
|
+
schema.pre('save', function () {
|
|
51
|
+
if (!this.isModified('roles')) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isRoleChangeAllowed()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Unauthorized: prevent role escalation
|
|
60
|
+
const currentUser = RequestContext.getCurrentUser();
|
|
61
|
+
logger.debug(
|
|
62
|
+
`[nest-server] mongooseRoleGuardPlugin: Blocked role change on ${this.isNew ? 'new' : 'existing'} document by user ${currentUser?.id || 'unknown'}`,
|
|
63
|
+
);
|
|
64
|
+
if (this.isNew) {
|
|
65
|
+
this['roles'] = [];
|
|
66
|
+
} else {
|
|
67
|
+
// Revert to original value
|
|
68
|
+
this.unmarkModified('roles');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Pre-findOneAndUpdate hook
|
|
73
|
+
schema.pre('findOneAndUpdate', function () {
|
|
74
|
+
handleUpdateRoleGuard(this.getUpdate());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Pre-updateOne hook
|
|
78
|
+
schema.pre('updateOne', function () {
|
|
79
|
+
handleUpdateRoleGuard(this.getUpdate());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pre-updateMany hook
|
|
83
|
+
schema.pre('updateMany', function () {
|
|
84
|
+
handleUpdateRoleGuard(this.getUpdate());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if the current user is allowed to modify roles.
|
|
90
|
+
* Returns true if:
|
|
91
|
+
* - No user context (system operation)
|
|
92
|
+
* - bypassRoleGuard is active (via RequestContext.runWithBypassRoleGuard())
|
|
93
|
+
* - User has ADMIN role
|
|
94
|
+
* - User has one of the configured allowedRoles
|
|
95
|
+
*/
|
|
96
|
+
function isRoleChangeAllowed(): boolean {
|
|
97
|
+
const currentUser = RequestContext.getCurrentUser();
|
|
98
|
+
// No user context (system operation) → allow
|
|
99
|
+
if (!currentUser) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
// Explicit bypass (e.g. signUp with default roles, authorized service operations)
|
|
103
|
+
if (RequestContext.isBypassRoleGuard()) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
// Admin → always allow
|
|
107
|
+
if (currentUser.hasRole?.([RoleEnum.ADMIN]) || currentUser.roles?.includes(RoleEnum.ADMIN)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check configured allowedRoles
|
|
112
|
+
const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongooseRoleGuardPlugin;
|
|
113
|
+
if (pluginConfig && typeof pluginConfig === 'object' && 'allowedRoles' in pluginConfig && pluginConfig.allowedRoles) {
|
|
114
|
+
const allowedRoles: string[] = pluginConfig.allowedRoles as string[];
|
|
115
|
+
if (currentUser.roles?.some((role: string) => allowedRoles.includes(role))) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleUpdateRoleGuard(update: any) {
|
|
124
|
+
if (!update) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasRolesUpdate = update.roles || update.$set?.roles || update.$push?.roles || update.$addToSet?.roles;
|
|
129
|
+
if (!hasRolesUpdate) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isRoleChangeAllowed()) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Unauthorized: remove roles changes with debug log
|
|
138
|
+
const currentUser = RequestContext.getCurrentUser();
|
|
139
|
+
logger.debug(`Stripped unauthorized roles change from user ${currentUser?.id || 'unknown'}`);
|
|
140
|
+
delete update.roles;
|
|
141
|
+
if (update.$set?.roles) {
|
|
142
|
+
delete update.$set.roles;
|
|
143
|
+
}
|
|
144
|
+
if (update.$push?.roles) {
|
|
145
|
+
delete update.$push.roles;
|
|
146
|
+
}
|
|
147
|
+
if (update.$addToSet?.roles) {
|
|
148
|
+
delete update.$addToSet.roles;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -74,8 +74,8 @@ export class ConfigService {
|
|
|
74
74
|
|
|
75
75
|
// Init subject handling
|
|
76
76
|
if (!isInitialized) {
|
|
77
|
-
ConfigService._configSubject$.subscribe((
|
|
78
|
-
ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(
|
|
77
|
+
ConfigService._configSubject$.subscribe((value) => {
|
|
78
|
+
ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(value)));
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CoreModel } from '../models/core-model.model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Central registry mapping Mongoose model names to CoreModel classes.
|
|
5
|
+
* Populated automatically by ModuleService constructors.
|
|
6
|
+
*/
|
|
7
|
+
export class ModelRegistry {
|
|
8
|
+
private static models = new Map<string, new (...args: any[]) => CoreModel>();
|
|
9
|
+
|
|
10
|
+
static register(dbModelName: string, modelClass: new (...args: any[]) => CoreModel): void {
|
|
11
|
+
this.models.set(dbModelName, modelClass);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static getModelClass(dbModelName: string): (new (...args: any[]) => CoreModel) | undefined {
|
|
15
|
+
return this.models.get(dbModelName);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static getAll(): Map<string, new (...args: any[]) => CoreModel> {
|
|
19
|
+
return this.models;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static clear(): void {
|
|
23
|
+
this.models.clear();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -5,10 +5,13 @@ import { ProcessType } from '../enums/process-type.enum';
|
|
|
5
5
|
import { getStringIds, popAndMap } from '../helpers/db.helper';
|
|
6
6
|
import { check } from '../helpers/input.helper';
|
|
7
7
|
import { prepareInput, prepareOutput } from '../helpers/service.helper';
|
|
8
|
+
import { getTranslatablePropertyKeys, updateLanguage } from '../decorators/translatable.decorator';
|
|
8
9
|
import { ServiceOptions } from '../interfaces/service-options.interface';
|
|
9
10
|
import { CoreModel } from '../models/core-model.model';
|
|
10
11
|
import { FieldSelection } from '../types/field-selection.type';
|
|
11
12
|
import { ConfigService } from './config.service';
|
|
13
|
+
import { ModelRegistry } from './model-registry.service';
|
|
14
|
+
import { RequestContext } from './request-context.service';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Module service class to be extended by concrete module services
|
|
@@ -40,6 +43,11 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
40
43
|
this.configService = options?.configService;
|
|
41
44
|
this.mainDbModel = options?.mainDbModel;
|
|
42
45
|
this.mainModelConstructor = options?.mainModelConstructor;
|
|
46
|
+
|
|
47
|
+
// Auto-register model class for ResponseModelInterceptor lookups
|
|
48
|
+
if (options?.mainModelConstructor && options?.mainDbModel?.modelName) {
|
|
49
|
+
ModelRegistry.register(options.mainDbModel.modelName, options.mainModelConstructor);
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/**
|
|
@@ -191,7 +199,10 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
// Run service function
|
|
194
|
-
|
|
202
|
+
// When force is enabled, bypass the Mongoose role guard plugin as well
|
|
203
|
+
let result = config.force
|
|
204
|
+
? await RequestContext.runWithBypassRoleGuard(() => serviceFunc(config))
|
|
205
|
+
: await serviceFunc(config);
|
|
195
206
|
|
|
196
207
|
// Pop and map main model
|
|
197
208
|
if (config.processFieldSelection && config.fieldSelection && this.processFieldSelection) {
|
|
@@ -242,6 +253,85 @@ export abstract class ModuleService<T extends CoreModel = any> {
|
|
|
242
253
|
return result;
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Simplified result processing for direct Mongoose queries.
|
|
258
|
+
* Handles population and output preparation without the full process() pipeline.
|
|
259
|
+
*
|
|
260
|
+
* Security (password hashing, role guard, createdBy/updatedBy, type mapping,
|
|
261
|
+
* field filtering, secret removal, translations) is handled automatically
|
|
262
|
+
* by the safety net (Mongoose plugins + interceptors).
|
|
263
|
+
*
|
|
264
|
+
* Use this when bypassing CrudService methods but still wanting
|
|
265
|
+
* population and custom prepareOutput() transformations.
|
|
266
|
+
*
|
|
267
|
+
* Note: This method does NOT perform authorization checks (checkRights).
|
|
268
|
+
* The caller is responsible for verifying permissions before calling this method.
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```typescript
|
|
272
|
+
* // Direct Mongoose query with result processing
|
|
273
|
+
* const doc = await this.mainDbModel.findById(id).exec();
|
|
274
|
+
* return this.processResult(doc, serviceOptions);
|
|
275
|
+
*
|
|
276
|
+
* // Aggregate with result processing
|
|
277
|
+
* const results = await this.mainDbModel.aggregate(pipeline).exec();
|
|
278
|
+
* return this.processResult(results, serviceOptions);
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
async processResult(result: any, serviceOptions: ServiceOptions = {}): Promise<any> {
|
|
282
|
+
if (!result) {
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Population (if GraphQL field selection is available)
|
|
287
|
+
if (serviceOptions.fieldSelection && this.processFieldSelection) {
|
|
288
|
+
const populateOpts = serviceOptions.processFieldSelection || {};
|
|
289
|
+
const items = Array.isArray(result) ? result : [result];
|
|
290
|
+
for (const item of items) {
|
|
291
|
+
await this.processFieldSelection(item, serviceOptions.fieldSelection, populateOpts);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Custom prepareOutput (type mapping, ObjectId conversion, custom overrides)
|
|
296
|
+
if (this.prepareOutput) {
|
|
297
|
+
result = await this.prepareOutput(result, serviceOptions);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply translation-aware input processing.
|
|
305
|
+
* Auto-detects @Translatable fields on the model and routes non-base-language
|
|
306
|
+
* values into _translations.
|
|
307
|
+
*
|
|
308
|
+
* @param input - The input data to transform
|
|
309
|
+
* @param oldDoc - The existing document from DB (needed for value comparison)
|
|
310
|
+
* @param language - Language code (defaults to RequestContext.getLanguage())
|
|
311
|
+
* @returns The transformed input with translations stored in _translations
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```typescript
|
|
315
|
+
* // In a custom update method:
|
|
316
|
+
* const oldDoc = await this.mainDbModel.findById(id).lean();
|
|
317
|
+
* input = this.applyTranslationInput(input, oldDoc, serviceOptions.language);
|
|
318
|
+
* return this.mainDbModel.findByIdAndUpdate(id, input, { returnDocument: 'after' }).exec();
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
protected applyTranslationInput(input: any, oldDoc: any, language?: string): any {
|
|
322
|
+
language = language || RequestContext.getLanguage();
|
|
323
|
+
if (!language) {
|
|
324
|
+
return input;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const translatableFields = getTranslatablePropertyKeys(this.mainModelConstructor);
|
|
328
|
+
if (translatableFields.length === 0) {
|
|
329
|
+
return input;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return updateLanguage(language, input, oldDoc, translatableFields);
|
|
333
|
+
}
|
|
334
|
+
|
|
245
335
|
/**
|
|
246
336
|
* Prepare input before save
|
|
247
337
|
*/
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
|
|
3
|
+
export interface IRequestContext {
|
|
4
|
+
currentUser?: {
|
|
5
|
+
id: string;
|
|
6
|
+
hasRole?: (roles: string[]) => boolean;
|
|
7
|
+
roles?: string[];
|
|
8
|
+
};
|
|
9
|
+
language?: string;
|
|
10
|
+
/** When true, mongooseRoleGuardPlugin allows role changes regardless of user permissions */
|
|
11
|
+
bypassRoleGuard?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Request-scoped context using AsyncLocalStorage.
|
|
16
|
+
* Provides access to the current user in Mongoose hooks and other
|
|
17
|
+
* places where NestJS request scope is not available.
|
|
18
|
+
*/
|
|
19
|
+
export class RequestContext {
|
|
20
|
+
private static storage = new AsyncLocalStorage<IRequestContext>();
|
|
21
|
+
|
|
22
|
+
static run<T>(context: IRequestContext, fn: () => T): T {
|
|
23
|
+
return this.storage.run(context, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static get(): IRequestContext | undefined {
|
|
27
|
+
return this.storage.getStore();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static getCurrentUser(): IRequestContext['currentUser'] | undefined {
|
|
31
|
+
return this.storage.getStore()?.currentUser;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static getLanguage(): string | undefined {
|
|
35
|
+
return this.storage.getStore()?.language;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if the role guard bypass is active for the current context.
|
|
40
|
+
*/
|
|
41
|
+
static isBypassRoleGuard(): boolean {
|
|
42
|
+
return this.storage.getStore()?.bypassRoleGuard === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a function with the role guard bypass enabled.
|
|
47
|
+
* The current context (user, language) is preserved; only bypassRoleGuard is added.
|
|
48
|
+
*
|
|
49
|
+
* Use this when authorized code needs to set roles on users, e.g.:
|
|
50
|
+
* - signUp with default roles
|
|
51
|
+
* - Admin panel where a non-admin role (e.g. HR_MANAGER) creates users with roles
|
|
52
|
+
* - System setup creating initial admin
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* await RequestContext.runWithBypassRoleGuard(async () => {
|
|
57
|
+
* await this.mainDbModel.findByIdAndUpdate(userId, { roles: ['EMPLOYEE'] });
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
static runWithBypassRoleGuard<T>(fn: () => T): T {
|
|
62
|
+
const currentStore = this.storage.getStore();
|
|
63
|
+
const context: IRequestContext = {
|
|
64
|
+
...currentStore,
|
|
65
|
+
bypassRoleGuard: true,
|
|
66
|
+
};
|
|
67
|
+
return this.storage.run(context, fn);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -40,8 +40,8 @@ const createPassportContext =
|
|
|
40
40
|
try {
|
|
41
41
|
request.authInfo = info;
|
|
42
42
|
return resolve(callback(err, user, info));
|
|
43
|
-
} catch (
|
|
44
|
-
reject(
|
|
43
|
+
} catch (callbackError) {
|
|
44
|
+
reject(callbackError);
|
|
45
45
|
}
|
|
46
46
|
})(request, response, (err: any) => (err ? reject(err) : resolve(undefined))),
|
|
47
47
|
);
|
|
@@ -342,9 +342,9 @@ export class CoreBetterAuthResolver {
|
|
|
342
342
|
// 1. accessToken (JWT plugin enriched response)
|
|
343
343
|
// 2. token (top-level, some BetterAuth versions)
|
|
344
344
|
// 3. session.token (session-based fallback)
|
|
345
|
-
const
|
|
345
|
+
const tokenResponse = response as any;
|
|
346
346
|
const rawToken =
|
|
347
|
-
|
|
347
|
+
tokenResponse.accessToken || tokenResponse.token || (hasSession(response) ? response.session.token : undefined);
|
|
348
348
|
const token = await this.resolveJwtToken(rawToken);
|
|
349
349
|
|
|
350
350
|
return {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Permissions Module Integration Checklist
|
|
2
|
+
|
|
3
|
+
## Reference Implementation
|
|
4
|
+
|
|
5
|
+
- Local: `node_modules/@lenne.tech/nest-server/src/server/` (scanned at runtime)
|
|
6
|
+
- GitHub: https://github.com/lenneTech/nest-server/tree/develop/src/core/modules/permissions
|
|
7
|
+
|
|
8
|
+
## Quick Setup
|
|
9
|
+
|
|
10
|
+
The Permissions module requires **no files to create** in the consuming project. It only needs a configuration entry.
|
|
11
|
+
|
|
12
|
+
### 1. Enable in config.env.ts
|
|
13
|
+
|
|
14
|
+
Add the `permissions` property to your environment configuration:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// config.env.ts - local/development only
|
|
18
|
+
permissions: true,
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**WHY only dev/local?** This is a development tool that exposes security metadata. It should never run in production.
|
|
22
|
+
|
|
23
|
+
### 2. Verify (Optional)
|
|
24
|
+
|
|
25
|
+
Start your server and navigate to:
|
|
26
|
+
|
|
27
|
+
- `http://localhost:3000/permissions` - HTML dashboard
|
|
28
|
+
- `http://localhost:3000/permissions/json` - Raw JSON
|
|
29
|
+
|
|
30
|
+
## Configuration Options
|
|
31
|
+
|
|
32
|
+
| Config | Access | Description |
|
|
33
|
+
| ------------------------------------ | ---------- | ---------------------------------------------------- |
|
|
34
|
+
| `true` | Admin only | Default, requires `RoleEnum.ADMIN` at `/permissions` |
|
|
35
|
+
| `{ role: 'S_EVERYONE' }` | Any role | Custom role for access |
|
|
36
|
+
| `{ role: false }` | No auth | No authentication required |
|
|
37
|
+
| `{ path: 'admin/permissions' }` | Admin only | Custom endpoint path (default: `permissions`) |
|
|
38
|
+
| `{ role: false, path: 'dev/perms' }` | No auth | Custom path + no auth |
|
|
39
|
+
| `{ enabled: false }` | Disabled | Explicitly disabled |
|
|
40
|
+
| `undefined` | Disabled | Default (not configured) |
|
|
41
|
+
|
|
42
|
+
## Verification Checklist
|
|
43
|
+
|
|
44
|
+
- [ ] `permissions: true` added to local/dev config only
|
|
45
|
+
- [ ] Server starts without errors
|
|
46
|
+
- [ ] `/permissions` returns HTML dashboard (requires admin login)
|
|
47
|
+
- [ ] `/permissions/json` returns JSON report
|
|
48
|
+
- [ ] Production config does NOT include `permissions`
|
|
49
|
+
|
|
50
|
+
## Common Mistakes
|
|
51
|
+
|
|
52
|
+
| Mistake | Symptom | Fix |
|
|
53
|
+
| ---------------------------- | ---------------------------------- | ------------------------------------------------------------ |
|
|
54
|
+
| Enabled in production config | Security metadata exposed publicly | Only enable in `local` / `dev` environments |
|
|
55
|
+
| Missing admin token | 401 Unauthorized on `/permissions` | Login as admin first, or use `{ role: false }` for local dev |
|
|
56
|
+
| No modules found | Empty report | Ensure `src/server/modules/` exists with module directories |
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Permissions Report Module
|
|
2
|
+
|
|
3
|
+
A development tool that scans `src/server/modules/` for `@Roles`, `@Restricted`, and `securityCheck()` usage, then generates an interactive HTML dashboard.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Provides a real-time overview of all role-based access control configuration across your project, helping identify:
|
|
8
|
+
|
|
9
|
+
- Endpoints without `@Roles` protection
|
|
10
|
+
- Models without `@Restricted` class-level restrictions
|
|
11
|
+
- Models missing `securityCheck()` overrides
|
|
12
|
+
- Fields without role restrictions
|
|
13
|
+
|
|
14
|
+
## Endpoints
|
|
15
|
+
|
|
16
|
+
The base path defaults to `/permissions` but can be customized via the `path` config option.
|
|
17
|
+
|
|
18
|
+
| Method | Path | Content-Type | Description |
|
|
19
|
+
| ------ | ------------------ | ------------------ | ----------------------------------------- |
|
|
20
|
+
| `GET` | `/{path}` | `text/html` | Interactive HTML dashboard |
|
|
21
|
+
| `GET` | `/{path}/json` | `application/json` | JSON report with `stats` field |
|
|
22
|
+
| `GET` | `/{path}/markdown` | `text/plain` | Markdown report (optimized for AI agents) |
|
|
23
|
+
| `POST` | `/{path}/rescan` | `application/json` | Force a rescan (rate limited: 1 per 10s) |
|
|
24
|
+
|
|
25
|
+
## JSON Stats
|
|
26
|
+
|
|
27
|
+
The JSON report includes a `stats` field with pre-calculated metrics:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"stats": {
|
|
32
|
+
"totalModules": 5,
|
|
33
|
+
"totalModels": 8,
|
|
34
|
+
"totalEndpoints": 42,
|
|
35
|
+
"totalSubObjects": 3,
|
|
36
|
+
"totalWarnings": 12,
|
|
37
|
+
"endpointCoverage": 85,
|
|
38
|
+
"securityCoverage": 62,
|
|
39
|
+
"warningsByType": {
|
|
40
|
+
"NO_RESTRICTION": 3,
|
|
41
|
+
"NO_ROLES": 2,
|
|
42
|
+
"NO_SECURITY_CHECK": 3,
|
|
43
|
+
"UNRESTRICTED_FIELD": 2,
|
|
44
|
+
"UNRESTRICTED_METHOD": 2
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **endpointCoverage**: % of methods with @Roles (own or class-level)
|
|
51
|
+
- **securityCoverage**: % of models with both @Restricted AND securityCheck()
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Add `permissions` to your `config.env.ts`:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// Enable with admin-only access (default)
|
|
59
|
+
permissions: true,
|
|
60
|
+
|
|
61
|
+
// Enable with custom role
|
|
62
|
+
permissions: { role: 'S_EVERYONE' },
|
|
63
|
+
|
|
64
|
+
// Enable without authentication
|
|
65
|
+
permissions: { role: false },
|
|
66
|
+
|
|
67
|
+
// Custom endpoint path (e.g. /admin/permissions instead of /permissions)
|
|
68
|
+
permissions: { path: 'admin/permissions' },
|
|
69
|
+
|
|
70
|
+
// Explicitly disabled
|
|
71
|
+
permissions: { enabled: false },
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Follows the [Boolean Shorthand Pattern](/.claude/rules/configurable-features.md).
|
|
75
|
+
|
|
76
|
+
## How It Works
|
|
77
|
+
|
|
78
|
+
1. **Lazy scanning**: The first request to `/permissions` triggers a full scan using [ts-morph](https://ts-morph.com/) to parse TypeScript AST
|
|
79
|
+
2. **Caching**: Results are cached in memory until a `.ts` file changes in `src/server/`
|
|
80
|
+
3. **File watching**: A recursive `fs.watch` on `src/server/` invalidates the cache on changes
|
|
81
|
+
4. **Inheritance resolution**: Walks `node_modules/@lenne.tech/nest-server` to resolve inherited fields from `Core*` base classes
|
|
82
|
+
5. **Security gap detection**: Compares found decorators against expected patterns and reports warnings
|
|
83
|
+
|
|
84
|
+
## Access Control
|
|
85
|
+
|
|
86
|
+
By default, only users with `RoleEnum.ADMIN` can access the endpoints. This is applied via `Reflect.defineMetadata('roles', ...)` at module registration time, which is read by `RolesGuard`.
|
|
87
|
+
|
|
88
|
+
## Architecture
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
CorePermissionsModule.forRoot(config)
|
|
92
|
+
-> CorePermissionsController (REST endpoints)
|
|
93
|
+
-> CorePermissionsService (caching, file watcher, HTML/Markdown generation)
|
|
94
|
+
-> permissions-scanner.ts (standalone AST scanning, shared with lt CLI)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The architecture is split into:
|
|
98
|
+
|
|
99
|
+
- **`permissions-scanner.ts`** (standalone, no NestJS dependencies): All AST-based scan logic using `ts-morph`. This is the single source of truth, also used by the `lt server permissions` CLI command via dynamic import.
|
|
100
|
+
- **`CorePermissionsService`**: Caching, rate limiting, file watching, HTML/Markdown generation. Delegates scanning to `scanPermissions()`.
|
|
101
|
+
- **`CorePermissionsController`**: REST endpoints with dynamically configurable base path.
|
|
102
|
+
- **`CorePermissionsModule`**: DynamicModule with `forRoot()`, configures role and path via `Reflect.defineMetadata`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Controller, Get, Header, Headers, Post } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { CorePermissionsService } from './core-permissions.service';
|
|
4
|
+
import type { PermissionsReport } from './interfaces/permissions.interface';
|
|
5
|
+
|
|
6
|
+
@Controller()
|
|
7
|
+
export class CorePermissionsController {
|
|
8
|
+
constructor(private readonly permissionsService: CorePermissionsService) {}
|
|
9
|
+
|
|
10
|
+
@Get()
|
|
11
|
+
@Header('Content-Type', 'text/html')
|
|
12
|
+
async getPermissionsHtml(@Headers('authorization') authHeader?: string): Promise<string> {
|
|
13
|
+
await this.permissionsService.getOrScan();
|
|
14
|
+
return this.permissionsService.generateHtml(authHeader);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Get('json')
|
|
18
|
+
async getPermissionsJson(): Promise<PermissionsReport> {
|
|
19
|
+
return this.permissionsService.getOrScan();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Get('markdown')
|
|
23
|
+
@Header('Content-Type', 'text/plain; charset=utf-8')
|
|
24
|
+
async getPermissionsMarkdown(): Promise<string> {
|
|
25
|
+
await this.permissionsService.getOrScan();
|
|
26
|
+
return this.permissionsService.generateMarkdown();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Post('rescan')
|
|
30
|
+
async rescan() {
|
|
31
|
+
await this.permissionsService.scan();
|
|
32
|
+
return { message: 'Rescan completed', timestamp: new Date().toISOString() };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DynamicModule, Module } from '@nestjs/common';
|
|
2
|
+
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
3
|
+
|
|
4
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
5
|
+
|
|
6
|
+
import { CorePermissionsController } from './core-permissions.controller';
|
|
7
|
+
import { CorePermissionsService } from './core-permissions.service';
|
|
8
|
+
import type { IPermissions } from './interfaces/permissions.interface';
|
|
9
|
+
|
|
10
|
+
@Module({})
|
|
11
|
+
export class CorePermissionsModule {
|
|
12
|
+
static forRoot(config: boolean | IPermissions): DynamicModule {
|
|
13
|
+
const role = typeof config === 'object' && config.role !== undefined ? config.role : RoleEnum.ADMIN;
|
|
14
|
+
|
|
15
|
+
const path = typeof config === 'object' && config.path ? config.path : 'permissions';
|
|
16
|
+
|
|
17
|
+
// Apply role-based access control at the class level using Reflect.defineMetadata
|
|
18
|
+
// instead of the @Roles() decorator because the role value is determined at runtime
|
|
19
|
+
// from the configuration. RolesGuard reads this metadata to enforce access control.
|
|
20
|
+
if (role !== false) {
|
|
21
|
+
Reflect.defineMetadata('roles', [role], CorePermissionsController);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Override the controller's @Controller() path with the configured path.
|
|
25
|
+
// This allows users to serve the permissions report under a custom route (e.g. 'admin/permissions')
|
|
26
|
+
// while keeping the default 'permissions' path when no custom path is specified.
|
|
27
|
+
Reflect.defineMetadata(PATH_METADATA, path, CorePermissionsController);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
controllers: [CorePermissionsController],
|
|
31
|
+
exports: [CorePermissionsService],
|
|
32
|
+
module: CorePermissionsModule,
|
|
33
|
+
providers: [{ provide: 'PERMISSIONS_PATH', useValue: path }, CorePermissionsService],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|