Skip to content

Commit

Permalink
Restriction handling optimized
Browse files Browse the repository at this point in the history
  • Loading branch information
kaihaase committed Dec 5, 2024
1 parent ee2c9ab commit 123c7e3
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 150 deletions.
180 changes: 84 additions & 96 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lenne.tech/nest-server",
"version": "10.6.0",
"version": "10.7.0",
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
"keywords": [
"node",
Expand Down Expand Up @@ -66,10 +66,10 @@
"@getbrevo/brevo": "1.0.1",
"@lenne.tech/mongoose-gridfs": "1.4.2",
"@lenne.tech/multer-gridfs-storage": "5.0.6",
"@nestjs/apollo": "12.2.1",
"@nestjs/apollo": "12.2.2",
"@nestjs/common": "10.4.13",
"@nestjs/core": "10.4.13",
"@nestjs/graphql": "12.2.1",
"@nestjs/graphql": "12.2.2",
"@nestjs/jwt": "10.2.0",
"@nestjs/mongoose": "10.1.0",
"@nestjs/passport": "10.0.3",
Expand Down Expand Up @@ -114,7 +114,7 @@
"@nestjs/schematics": "10.2.3",
"@nestjs/testing": "10.4.13",
"@swc/cli": "0.5.2",
"@swc/core": "1.9.3",
"@swc/core": "1.10.0",
"@swc/jest": "0.2.37",
"@types/compression": "1.7.5",
"@types/cookie-parser": "1.4.8",
Expand Down Expand Up @@ -143,7 +143,7 @@
"jest": "29.7.0",
"npm-watch": "0.13.0",
"pm2": "5.4.3",
"prettier": "3.4.1",
"prettier": "3.4.2",
"pretty-quick": "4.0.0",
"supertest": "7.0.0",
"ts-jest": "29.2.5",
Expand Down
2 changes: 1 addition & 1 deletion spectaql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ servers:
info:
title: lT Nest Server
description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).
version: 10.6.0
version: 10.7.0
contact:
name: lenne.Tech GmbH
url: https://lenne.tech
Expand Down
38 changes: 32 additions & 6 deletions src/core/common/decorators/restricted.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'reflect-metadata';

import { ProcessType } from '../enums/process-type.enum';
import { RoleEnum } from '../enums/role.enum';
import { getIncludedIds } from '../helpers/db.helper';
import { equalIds, getIncludedIds } from '../helpers/db.helper';
import { RequireAtLeastOne } from '../types/required-at-least-one.type';

import _ = require('lodash');
Expand All @@ -22,6 +22,7 @@ export type RestrictedType = (
'memberOf' | 'roles'
>
| string
| string[]
)[];

/**
Expand Down Expand Up @@ -63,11 +64,15 @@ export const checkRestricted = (
data: any,
user: { hasRole: (roles: string[]) => boolean; id: any },
options: {
allowCreatorOfParent?: boolean;
checkObjectItself?: boolean;
dbObject?: any;
debug?: boolean;
ignoreFunctions?: boolean;
ignoreUndefined?: boolean;
isCreatorOfParent?: boolean;
mergeRoles?: boolean;
noteCheckedObjects?: boolean;
processType?: ProcessType;
removeUndefinedFromResultArray?: boolean;
throwError?: boolean;
Expand All @@ -78,16 +83,20 @@ export const checkRestricted = (
// For Input: throwError = true
// For Output: throwError = false
const config = {
allowCreatorOfParent: true,
checkObjectItself: false,
ignoreFunctions: true,
ignoreUndefined: true,
isCreatorOfParent: false,
mergeRoles: true,
noteCheckedObjects: true,
removeUndefinedFromResultArray: true,
throwError: true,
...options,
};

// Primitives
if (!data || typeof data !== 'object') {
if (!data || typeof data !== 'object' || (config.noteCheckedObjects && data._objectAlreadyCheckedForRestrictions)) {
return data;
}

Expand All @@ -109,14 +118,18 @@ export const checkRestricted = (

// Check function
const validateRestricted = (restricted) => {
if (config.noteCheckedObjects && data?._objectAlreadyCheckedForRestrictions) {
return true;
}

// Check restrictions
if (!restricted?.length) {
return true;
}

let valid = false;

// Get roles
// Get roles restricted element
const roles: string[] = [];
restricted.forEach((item) => {
if (typeof item === 'string') {
Expand All @@ -130,6 +143,8 @@ export const checkRestricted = (
} else {
roles.push(item.roles);
}
} else if (Array.isArray(item)) {
roles.push(...item);
}
});

Expand All @@ -145,8 +160,10 @@ export const checkRestricted = (
roles.includes(RoleEnum.S_EVERYONE)
|| user?.hasRole?.(roles)
|| (user?.id && roles.includes(RoleEnum.S_USER))
|| (roles.includes(RoleEnum.S_SELF) && getIncludedIds(config.dbObject, user))
|| (roles.includes(RoleEnum.S_CREATOR) && getIncludedIds(config.dbObject?.createdBy, user))
|| (roles.includes(RoleEnum.S_SELF) && equalIds(data, user))
|| (roles.includes(RoleEnum.S_CREATOR)
&& (('createdBy' in data && equalIds(data.createdBy, user))
|| (config.allowCreatorOfParent && !('createdBy' in data) && config.isCreatorOfParent)))
) {
valid = true;
}
Expand Down Expand Up @@ -200,7 +217,7 @@ export const checkRestricted = (
return valid;
};

// Check object
// Check data object
const objectRestrictions = getRestricted(data.constructor) || [];
if (config.checkObjectItself) {
const objectIsValid = validateRestricted(objectRestrictions);
Expand All @@ -218,6 +235,11 @@ export const checkRestricted = (

// Check properties of object
for (const propertyKey of Object.keys(data)) {
// Ignore functions
if (typeof data[propertyKey] === 'function' && config.ignoreFunctions) {
continue;
}

// Check undefined
if (data[propertyKey] === undefined && config.ignoreUndefined) {
continue;
Expand All @@ -230,6 +252,10 @@ export const checkRestricted = (

// Check rights
if (valid) {
// Check if data is user or user is creator of data (for nested plain objects)
config.isCreatorOfParent
= equalIds(data, user) || ('createdBy' in data ? equalIds(data.createdBy, user) : config.isCreatorOfParent);

// Check deep
data[propertyKey] = checkRestricted(data[propertyKey], user, config, processedObjects);
} else {
Expand Down
6 changes: 5 additions & 1 deletion src/core/common/helpers/db.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,5 +670,9 @@ function getStringId(element: any): string {
}

// Other types
return element.toString();
if (typeof element.toString === 'function') {
return element.toString();
}

return undefined;
}
20 changes: 16 additions & 4 deletions src/core/common/helpers/input.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,9 @@ export async function check(
value: any,
user: { hasRole: (roles: string[]) => boolean; id: any },
options?: {
allowCreatorOfParent?: boolean;
dbObject?: any;
isCreatorOfParent?: boolean;
metatype?: any;
processType?: ProcessType;
roles?: string | string[];
Expand All @@ -221,6 +223,8 @@ export async function check(
},
): Promise<any> {
const config = {
allowCreatorOfParent: true,
isCreatorOfParent: false,
throwError: true,
...options,
validatorOptions: {
Expand Down Expand Up @@ -254,7 +258,12 @@ export async function check(
// check if the user is herself / himself
|| (roles.includes(RoleEnum.S_SELF) && equalIds(config.dbObject, user))
// check if the user is the creator
|| (roles.includes(RoleEnum.S_CREATOR) && equalIds(config.dbObject?.createdBy, user))
|| (roles.includes(RoleEnum.S_CREATOR)
&& ((config.dbObject && 'createdBy' in config.dbObject && equalIds(config.dbObject.createdBy, user))
|| (config.allowCreatorOfParent
&& config.dbObject
&& !('createdBy' in config.dbObject)
&& config.isCreatorOfParent)))
) {
valid = true;
}
Expand All @@ -263,6 +272,9 @@ export async function check(
}
}

// Object check is done
delete config.roles;

// Return value if it is only a basic type
if (!value || typeof value !== 'object') {
return value;
Expand All @@ -278,12 +290,12 @@ export async function check(

const metatype = config.metatype;
if (metatype) {
// Check metatype
// If metatype is a basic type, additional checks are not possible
if (isBasicType(metatype)) {
return value;
}

// Convert to metatype
// Convert to metatype, validate rights
if (!(value instanceof metatype)) {
if ((metatype as any)?.map) {
value = (metatype as any)?.map(value);
Expand All @@ -293,7 +305,7 @@ export async function check(
}
}

// Validate
// Validate values (like isEmail, isNumber, etc.)
const errors = await validate(value, config.validatorOptions);
if (errors.length > 0 && config.throwError) {
throw new BadRequestException('Validation failed');
Expand Down
14 changes: 13 additions & 1 deletion src/core/common/interceptors/check-response.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class CheckResponseInterceptor implements NestInterceptor {
debug: false,
ignoreUndefined: true,
mergeRoles: true,
noteCheckedObjects: true,
removeUndefinedFromResultArray: true,
throwError: false,
};
Expand All @@ -38,7 +39,18 @@ export class CheckResponseInterceptor implements NestInterceptor {
return next.handle().pipe(
map((data) => {
// Prepare response data for current user
return checkRestricted(data, currentUser, this.config);
const start = Date.now();
const result = checkRestricted(data, currentUser, this.config);
if (
this.config.debug
&& Date.now() - start >= (typeof this.config.debug === 'number' ? this.config.debug : 100)
) {
console.warn(
`Duration for CheckResponseInterceptor is too long: ${Date.now() - start}ms`,
Array.isArray(data) ? `${data[0].constructor.name}[]: ${data.length}` : data?.constructor?.name,
);
}
return result;
}),
);
}
Expand Down
39 changes: 37 additions & 2 deletions src/core/common/interceptors/check-security.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,29 @@ import { map } from 'rxjs/operators';
import { getContextData } from '../helpers/context.helper';
import { getStringIds } from '../helpers/db.helper';
import { processDeep } from '../helpers/input.helper';
import { ConfigService } from '../services/config.service';

/**
* Verification of all outgoing data via securityCheck
*/
@Injectable()
export class CheckSecurityInterceptor implements NestInterceptor {
config = {
debug: false,
noteCheckedObjects: true,
};

constructor(private readonly configService: ConfigService) {
const configuration = this.configService.getFastButReadOnly('security.checkSecurityInterceptor');
if (typeof configuration === 'object') {
this.config = { ...this.config, ...configuration };
}
}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Start time
const start = Date.now();

// Get current user
const user = getContextData(context)?.currentUser || null;

Expand All @@ -25,7 +41,17 @@ export class CheckSecurityInterceptor implements NestInterceptor {
}
}

const check = (data) => {
// Data from next for check
let objectData: any;

const check = (data: any) => {
objectData = data;

// Check if data already checked
if (this.config.noteCheckedObjects && data?._objectAlreadyCheckedForRestrictions) {
return data;
}

// Check data
if (data && typeof data === 'object' && typeof data.securityCheck === 'function') {
const dataJson = JSON.stringify(data);
Expand Down Expand Up @@ -73,6 +99,15 @@ export class CheckSecurityInterceptor implements NestInterceptor {
};

// Check response
return next.handle().pipe(map(check));
const result = next.handle().pipe(map(check));
if (this.config.debug && Date.now() - start >= (typeof this.config.debug === 'number' ? this.config.debug : 100)) {
console.warn(
`Duration for CheckResponseInterceptor is too long: ${Date.now() - start}ms`,
Array.isArray(objectData)
? `${objectData[0].constructor.name}[]: ${objectData.length}`
: objectData?.constructor?.name,
);
}
return result;
}
}
Loading

0 comments on commit 123c7e3

Please sign in to comment.