@lenne.tech/nest-server 11.17.0 → 11.19.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 +2 -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 +14 -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 +4 -2
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/error-code/core-error-code.controller.js +1 -1
- package/dist/core/modules/error-code/core-error-code.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core.module.js +36 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-code.controller.js +1 -1
- package/dist/server/modules/error-code/error-code.controller.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 +2 -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 +162 -2
- 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 +4 -2
- package/src/core/modules/error-code/INTEGRATION-CHECKLIST.md +8 -8
- package/src/core/modules/error-code/core-error-code.controller.ts +3 -3
- package/src/core/modules/error-code/interfaces/error-code.interfaces.ts +1 -1
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +5 -5
- package/src/core/modules/system-setup/README.md +9 -9
- package/src/core/modules/system-setup/core-system-setup.controller.ts +1 -1
- package/src/core.module.ts +55 -4
- package/src/index.ts +10 -0
- package/src/server/modules/error-code/README.md +5 -5
- package/src/server/modules/error-code/error-code.controller.ts +3 -3
|
@@ -53,7 +53,7 @@ export interface IAuth {
|
|
|
53
53
|
*
|
|
54
54
|
* Legacy endpoints include:
|
|
55
55
|
* - GraphQL: signIn, signUp, signOut, refreshToken mutations
|
|
56
|
-
* - REST: /
|
|
56
|
+
* - REST: /auth/* endpoints
|
|
57
57
|
*
|
|
58
58
|
* These can be disabled once all users have migrated to BetterAuth (IAM).
|
|
59
59
|
*
|
|
@@ -156,7 +156,7 @@ export interface IAuthLegacyEndpoints {
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* Whether legacy REST auth endpoints are enabled.
|
|
159
|
-
* Affects: /
|
|
159
|
+
* Affects: /auth/sign-in, /auth/sign-up, etc.
|
|
160
160
|
*
|
|
161
161
|
* @default true (inherits from `enabled`)
|
|
162
162
|
*/
|
|
@@ -1426,6 +1426,26 @@ export interface IServerOptions {
|
|
|
1426
1426
|
* default = true
|
|
1427
1427
|
*/
|
|
1428
1428
|
noteCheckedObjects?: boolean;
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Whether to remove known secret fields as a fallback safety net.
|
|
1432
|
+
* Applied after securityCheck() to catch any remaining secrets.
|
|
1433
|
+
* default = true
|
|
1434
|
+
*
|
|
1435
|
+
* @since 11.18.0
|
|
1436
|
+
*/
|
|
1437
|
+
removeSecretFields?: boolean;
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* List of field names to remove in the fallback secret removal.
|
|
1441
|
+
* Only used when removeSecretFields is true.
|
|
1442
|
+
* Note: security.secretFields (global) takes precedence over this setting if both are defined.
|
|
1443
|
+
* Use this for interceptor-specific overrides, or security.secretFields for a global default.
|
|
1444
|
+
* default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
|
|
1445
|
+
*
|
|
1446
|
+
* @since 11.18.0
|
|
1447
|
+
*/
|
|
1448
|
+
secretFields?: string[];
|
|
1429
1449
|
};
|
|
1430
1450
|
|
|
1431
1451
|
/**
|
|
@@ -1449,6 +1469,146 @@ export interface IServerOptions {
|
|
|
1449
1469
|
*/
|
|
1450
1470
|
nonWhitelistedFields?: 'strip' | 'error' | false;
|
|
1451
1471
|
};
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Mongoose password hashing plugin - automatically hashes passwords on save/update
|
|
1475
|
+
* (save, findOneAndUpdate, updateOne, updateMany).
|
|
1476
|
+
* Prevents plaintext passwords even when CrudService.process() is bypassed.
|
|
1477
|
+
* Already-hashed values (BCrypt pattern /^\$2[aby]\$\d+\$/) are detected and skipped
|
|
1478
|
+
* to prevent double-hashing.
|
|
1479
|
+
*
|
|
1480
|
+
* For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
|
|
1481
|
+
* skipPatterns so they are preserved as-is and not hashed.
|
|
1482
|
+
*
|
|
1483
|
+
* default = true (opt-out via false)
|
|
1484
|
+
*
|
|
1485
|
+
* @example
|
|
1486
|
+
* ```typescript
|
|
1487
|
+
* // Default: hash all passwords
|
|
1488
|
+
* mongoosePasswordPlugin: true,
|
|
1489
|
+
*
|
|
1490
|
+
* // Skip sentinel values used for account locking
|
|
1491
|
+
* mongoosePasswordPlugin: { skipPatterns: ['^!LOCKED:'] },
|
|
1492
|
+
*
|
|
1493
|
+
* // Disable entirely
|
|
1494
|
+
* mongoosePasswordPlugin: false,
|
|
1495
|
+
* ```
|
|
1496
|
+
*
|
|
1497
|
+
* @since 11.18.0
|
|
1498
|
+
*/
|
|
1499
|
+
mongoosePasswordPlugin?:
|
|
1500
|
+
| boolean
|
|
1501
|
+
| {
|
|
1502
|
+
/**
|
|
1503
|
+
* Regex patterns for password values that should NOT be hashed.
|
|
1504
|
+
* Use this for sentinel/lock values like '!LOCKED:REQUIRES_PASSWORD_RESET'
|
|
1505
|
+
* that are intentionally invalid hashes to prevent login.
|
|
1506
|
+
* Patterns are matched against the raw password value.
|
|
1507
|
+
*
|
|
1508
|
+
* @example ['^!LOCKED:', '^\\$NOHASH\\$']
|
|
1509
|
+
*/
|
|
1510
|
+
skipPatterns?: (string | RegExp)[];
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Mongoose audit fields plugin - automatically sets createdBy/updatedBy fields
|
|
1515
|
+
* on save/update operations.
|
|
1516
|
+
* Uses RequestContext (AsyncLocalStorage) to access the current user —
|
|
1517
|
+
* requires RequestContextMiddleware to be active (registered automatically by CoreModule).
|
|
1518
|
+
* Only activates on schemas that have createdBy and/or updatedBy fields defined.
|
|
1519
|
+
* default = true (opt-out via false)
|
|
1520
|
+
*
|
|
1521
|
+
* @since 11.18.0
|
|
1522
|
+
*/
|
|
1523
|
+
mongooseAuditFieldsPlugin?: boolean;
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Mongoose role guard plugin - prevents unauthorized users from escalating roles
|
|
1527
|
+
* via save/update operations.
|
|
1528
|
+
* Uses RequestContext (AsyncLocalStorage) to access the current user —
|
|
1529
|
+
* requires RequestContextMiddleware to be active (registered automatically by CoreModule).
|
|
1530
|
+
* System operations without a request context (e.g. migrations) are allowed through.
|
|
1531
|
+
*
|
|
1532
|
+
* By default, only ADMIN users can assign roles. Use allowedRoles to permit
|
|
1533
|
+
* additional roles (e.g. ORGA, COMPANY_ADMIN) to assign roles to other users.
|
|
1534
|
+
*
|
|
1535
|
+
* default = true (opt-out via false)
|
|
1536
|
+
*
|
|
1537
|
+
* @example
|
|
1538
|
+
* ```typescript
|
|
1539
|
+
* // Default: only ADMIN can assign roles
|
|
1540
|
+
* mongooseRoleGuardPlugin: true,
|
|
1541
|
+
*
|
|
1542
|
+
* // Allow ADMIN and custom roles to assign roles
|
|
1543
|
+
* mongooseRoleGuardPlugin: { allowedRoles: ['ORGA', 'COMPANY_ADMIN'] },
|
|
1544
|
+
*
|
|
1545
|
+
* // Disable entirely
|
|
1546
|
+
* mongooseRoleGuardPlugin: false,
|
|
1547
|
+
* ```
|
|
1548
|
+
*
|
|
1549
|
+
* @since 11.18.0
|
|
1550
|
+
*/
|
|
1551
|
+
mongooseRoleGuardPlugin?:
|
|
1552
|
+
| boolean
|
|
1553
|
+
| {
|
|
1554
|
+
/**
|
|
1555
|
+
* Additional roles (beyond ADMIN) that are allowed to assign roles to other users.
|
|
1556
|
+
* ADMIN is always implicitly allowed — no need to include it here.
|
|
1557
|
+
*/
|
|
1558
|
+
allowedRoles?: string[];
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* ResponseModelInterceptor - auto-converts plain objects/Mongoose documents
|
|
1563
|
+
* to model instances with securityCheck() and correct constructor metadata.
|
|
1564
|
+
* Ensures @Restricted field filtering and securityCheck() work even when
|
|
1565
|
+
* CrudService.process() is bypassed.
|
|
1566
|
+
*
|
|
1567
|
+
* Model class resolution order:
|
|
1568
|
+
* 1. Explicit @ResponseModel(ModelClass) decorator
|
|
1569
|
+
* 2. GraphQL TypeMetadataStorage (automatic for @Query/@Mutation return types)
|
|
1570
|
+
* 3. Swagger @ApiOkResponse / @ApiCreatedResponse type (automatic for REST)
|
|
1571
|
+
* 4. No conversion (existing interceptors work as before)
|
|
1572
|
+
*
|
|
1573
|
+
* Results are cached per route handler for zero-cost subsequent lookups.
|
|
1574
|
+
* default = true (opt-out via false)
|
|
1575
|
+
*
|
|
1576
|
+
* @since 11.18.0
|
|
1577
|
+
*/
|
|
1578
|
+
responseModelInterceptor?:
|
|
1579
|
+
| boolean
|
|
1580
|
+
| {
|
|
1581
|
+
/**
|
|
1582
|
+
* Log a warning when auto-conversion from plain object to model occurs.
|
|
1583
|
+
* Useful for identifying services that bypass CrudService.
|
|
1584
|
+
* default = false
|
|
1585
|
+
*/
|
|
1586
|
+
debug?: boolean;
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* TranslateResponseInterceptor - automatically applies _translations
|
|
1591
|
+
* to response objects based on the Accept-Language header of the request.
|
|
1592
|
+
* Reads @Translatable field values from _translations and overwrites
|
|
1593
|
+
* the base-language field with the requested translation.
|
|
1594
|
+
* Includes early bailout: skips recursive traversal when no _translations
|
|
1595
|
+
* are present in the response.
|
|
1596
|
+
* Ensures translations work even when CrudService.process() is bypassed.
|
|
1597
|
+
* default = true (opt-out via false)
|
|
1598
|
+
*
|
|
1599
|
+
* @since 11.18.0
|
|
1600
|
+
*/
|
|
1601
|
+
translateResponseInterceptor?: boolean;
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* Global list of field names that should always be removed from responses.
|
|
1605
|
+
* This is a fallback safety net applied after all other security checks.
|
|
1606
|
+
* Takes precedence over checkSecurityInterceptor.secretFields if both are set.
|
|
1607
|
+
* default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
|
|
1608
|
+
*
|
|
1609
|
+
* @since 11.18.0
|
|
1610
|
+
*/
|
|
1611
|
+
secretFields?: string[];
|
|
1452
1612
|
};
|
|
1453
1613
|
|
|
1454
1614
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { NextFunction, Request, Response } from 'express';
|
|
3
|
+
|
|
4
|
+
import { IRequestContext, RequestContext } from '../services/request-context.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Middleware that wraps each request in a RequestContext (AsyncLocalStorage).
|
|
8
|
+
* Uses a lazy getter for currentUser so that the user is resolved at access time,
|
|
9
|
+
* not at middleware execution time. This ensures that auth middleware that runs
|
|
10
|
+
* after this middleware still sets req.user before it's read.
|
|
11
|
+
*/
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class RequestContextMiddleware implements NestMiddleware {
|
|
14
|
+
use(req: Request, _res: Response, next: NextFunction) {
|
|
15
|
+
const context: IRequestContext = {
|
|
16
|
+
get currentUser() {
|
|
17
|
+
return (req as any).user || undefined;
|
|
18
|
+
},
|
|
19
|
+
get language() {
|
|
20
|
+
return req.headers?.['accept-language'] || undefined;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
RequestContext.run(context, () => next());
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -244,10 +244,10 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
|
|
|
244
244
|
// Special handling for validators with arrays when 'each' option is set
|
|
245
245
|
// The 'each' property indicates validation should be applied to each array element
|
|
246
246
|
const isArrayValue = Array.isArray(propertyValue);
|
|
247
|
-
const
|
|
247
|
+
const shouldValidateEachItem =
|
|
248
248
|
metadata.each === true || (metadata as any).validationOptions?.each === true;
|
|
249
249
|
|
|
250
|
-
if (isArrayValue &&
|
|
250
|
+
if (isArrayValue && shouldValidateEachItem) {
|
|
251
251
|
// Validate each array element individually
|
|
252
252
|
const results: boolean[] = [];
|
|
253
253
|
for (const item of propertyValue) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Plugin } from '@nestjs/apollo';
|
|
2
2
|
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
|
3
|
-
import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo
|
|
3
|
+
import type { ApolloServerPlugin, BaseContext, GraphQLRequestListener } from '@apollo/server';
|
|
4
4
|
import { GraphQLError } from 'graphql';
|
|
5
5
|
import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ export class ComplexityPlugin implements ApolloServerPlugin {
|
|
|
13
13
|
private configService: ConfigService,
|
|
14
14
|
) {}
|
|
15
15
|
|
|
16
|
-
async requestDidStart(): Promise<GraphQLRequestListener
|
|
16
|
+
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
|
|
17
17
|
const maxComplexity: number = this.configService.getFastButReadOnly('graphQl.maxComplexity');
|
|
18
18
|
const { schema } = this.gqlSchemaHost;
|
|
19
19
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { RequestContext } from '../services/request-context.service';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mongoose plugin that automatically sets createdBy/updatedBy fields.
|
|
5
|
+
* Uses RequestContext (AsyncLocalStorage) to access the current user.
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
* - No user context (system operations, seeding): fields are not set
|
|
9
|
+
* - New documents: sets createdBy (if not already set) and updatedBy
|
|
10
|
+
* - Existing documents on save: sets updatedBy
|
|
11
|
+
* - Update operations: sets updatedBy
|
|
12
|
+
*
|
|
13
|
+
* Only activates on schemas that have createdBy and/or updatedBy fields defined.
|
|
14
|
+
*/
|
|
15
|
+
export function mongooseAuditFieldsPlugin(schema) {
|
|
16
|
+
const hasCreatedBy = !!schema.path('createdBy');
|
|
17
|
+
const hasUpdatedBy = !!schema.path('updatedBy');
|
|
18
|
+
|
|
19
|
+
// Skip schemas without audit fields (e.g. BetterAuth sessions, third-party schemas)
|
|
20
|
+
if (!hasCreatedBy && !hasUpdatedBy) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pre-save hook
|
|
25
|
+
schema.pre('save', function () {
|
|
26
|
+
const currentUser = RequestContext.getCurrentUser();
|
|
27
|
+
if (!currentUser?.id) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (hasCreatedBy && this.isNew && !this['createdBy']) {
|
|
32
|
+
this['createdBy'] = currentUser.id;
|
|
33
|
+
}
|
|
34
|
+
if (hasUpdatedBy) {
|
|
35
|
+
this['updatedBy'] = currentUser.id;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Pre-update hooks (only needed for updatedBy)
|
|
40
|
+
if (hasUpdatedBy) {
|
|
41
|
+
const updateHook = function () {
|
|
42
|
+
const currentUser = RequestContext.getCurrentUser();
|
|
43
|
+
if (!currentUser?.id) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const update = this.getUpdate();
|
|
48
|
+
if (!update) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Handle both flat updates and $set operator
|
|
53
|
+
update['updatedBy'] = currentUser.id;
|
|
54
|
+
if (update.$set) {
|
|
55
|
+
update.$set['updatedBy'] = currentUser.id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Handle upsert: set createdBy on insert via $setOnInsert
|
|
59
|
+
// Only add if createdBy is not already in the update (avoids MongoDB path conflict)
|
|
60
|
+
if (hasCreatedBy && !update['createdBy'] && !update.$set?.['createdBy']) {
|
|
61
|
+
if (!update.$setOnInsert) {
|
|
62
|
+
update.$setOnInsert = {};
|
|
63
|
+
}
|
|
64
|
+
if (!update.$setOnInsert['createdBy']) {
|
|
65
|
+
update.$setOnInsert['createdBy'] = currentUser.id;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
schema.pre('findOneAndUpdate', updateHook);
|
|
71
|
+
schema.pre('updateOne', updateHook);
|
|
72
|
+
schema.pre('updateMany', updateHook);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import bcrypt = require('bcrypt');
|
|
2
|
+
import { sha256 } from 'js-sha256';
|
|
3
|
+
|
|
4
|
+
import { ConfigService } from '../services/config.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mongoose plugin that automatically hashes passwords before saving to the database.
|
|
8
|
+
* Handles save(), findOneAndUpdate(), updateOne(), and updateMany() operations.
|
|
9
|
+
*
|
|
10
|
+
* Prevents plaintext passwords from being stored even when developers bypass
|
|
11
|
+
* CrudService.process() and use direct Mongoose operations.
|
|
12
|
+
*
|
|
13
|
+
* Detects already-hashed values (BCrypt pattern) to prevent double-hashing.
|
|
14
|
+
*
|
|
15
|
+
* For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
|
|
16
|
+
* skipPatterns in security.mongoosePasswordPlugin to preserve them as-is.
|
|
17
|
+
*/
|
|
18
|
+
export function mongoosePasswordPlugin(schema) {
|
|
19
|
+
// Pre-save hook
|
|
20
|
+
schema.pre('save', async function () {
|
|
21
|
+
if (!this.isModified('password') || !this['password']) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this['password'] = await hashPassword(this['password']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Pre-findOneAndUpdate hook
|
|
28
|
+
schema.pre('findOneAndUpdate', async function () {
|
|
29
|
+
await hashUpdatePassword(this.getUpdate());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Pre-updateOne hook
|
|
33
|
+
schema.pre('updateOne', async function () {
|
|
34
|
+
await hashUpdatePassword(this.getUpdate());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Pre-updateMany hook
|
|
38
|
+
schema.pre('updateMany', async function () {
|
|
39
|
+
await hashUpdatePassword(this.getUpdate());
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function hashUpdatePassword(update: any) {
|
|
44
|
+
if (!update) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (update.password) {
|
|
48
|
+
update.password = await hashPassword(update.password);
|
|
49
|
+
}
|
|
50
|
+
if (update.$set?.password) {
|
|
51
|
+
update.$set.password = await hashPassword(update.$set.password);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Compiled RegExp cache (built once on first call)
|
|
56
|
+
let compiledSkipPatterns: RegExp[] | null = null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reset the compiled skip patterns cache.
|
|
60
|
+
* Use in test environments where ConfigService is reset between test suites.
|
|
61
|
+
*/
|
|
62
|
+
export function resetSkipPatternsCache(): void {
|
|
63
|
+
compiledSkipPatterns = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSkipPatterns(): RegExp[] {
|
|
67
|
+
if (compiledSkipPatterns !== null) {
|
|
68
|
+
return compiledSkipPatterns;
|
|
69
|
+
}
|
|
70
|
+
const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongoosePasswordPlugin;
|
|
71
|
+
if (pluginConfig && typeof pluginConfig === 'object' && 'skipPatterns' in pluginConfig && pluginConfig.skipPatterns) {
|
|
72
|
+
compiledSkipPatterns = (pluginConfig.skipPatterns as (string | RegExp)[]).map((p) =>
|
|
73
|
+
p instanceof RegExp ? p : new RegExp(p),
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
compiledSkipPatterns = [];
|
|
77
|
+
}
|
|
78
|
+
return compiledSkipPatterns;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
82
|
+
// Already BCrypt-hashed → skip
|
|
83
|
+
if (/^\$2[aby]\$\d+\$/.test(password)) {
|
|
84
|
+
return password;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check configured skip patterns (e.g. sentinel/lock values)
|
|
88
|
+
const skipPatterns = getSkipPatterns();
|
|
89
|
+
if (skipPatterns.some((pattern) => pattern.test(password))) {
|
|
90
|
+
return password;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// SHA256 pre-hash if configured and password is not already SHA256
|
|
94
|
+
const sha256Enabled = ConfigService.configFastButReadOnly?.sha256;
|
|
95
|
+
if (sha256Enabled && !/^[a-f0-9]{64}$/i.test(password)) {
|
|
96
|
+
password = sha256(password);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return bcrypt.hash(password, 10);
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|