From e8e657e8d1e2ed9e85e798472bc1f1335a5e8d41 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 23 Sep 2024 23:01:23 +0100 Subject: [PATCH 01/76] Validation: Strip unkown + Express/Auth added security, updated resource actions --- src/app/models/auth/User.ts | 19 +++ .../auth/commands/GenerateJWTSecret.ts | 3 +- .../auth/exceptions/ForbiddenResourceError.ts | 8 ++ .../domains/auth/interfaces/IAuthService.ts | 16 +++ .../auth/interfaces/ISecurityRequest.ts | 7 ++ .../domains/auth/interfaces/IUserModel.ts | 1 + .../auth/middleware/securityMiddleware.ts | 46 +++++++ .../domains/auth/providers/AuthProvider.ts | 3 +- src/core/domains/auth/services/AuthService.ts | 21 ++++ src/core/domains/auth/services/Security.ts | 119 ++++++++++++++++++ .../domains/auth/services/SecurityReader.ts | 52 ++++++++ .../domains/express/actions/resourceCreate.ts | 2 + .../domains/express/actions/resourceDelete.ts | 13 ++ .../domains/express/actions/resourceIndex.ts | 62 ++++++++- .../domains/express/actions/resourceShow.ts | 45 ++++++- .../domains/express/actions/resourceUpdate.ts | 13 ++ src/core/domains/express/interfaces/IRoute.ts | 2 + .../interfaces/IRouteResourceOptions.ts | 4 +- .../domains/express/middleware/authorize.ts | 2 + .../domains/express/routing/RouteResource.ts | 31 ++++- .../express/services/ExpressService.ts | 8 ++ .../domains/express/types/BaseRequest.t.ts | 3 +- .../domains/validator/base/BaseValidator.ts | 8 ++ .../validator/interfaces/IValidator.ts | 6 + .../middleware/validateMiddleware.ts | 8 +- src/core/services/App.ts | 22 ++++ 26 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 src/core/domains/auth/exceptions/ForbiddenResourceError.ts create mode 100644 src/core/domains/auth/interfaces/ISecurityRequest.ts create mode 100644 src/core/domains/auth/middleware/securityMiddleware.ts create mode 100644 src/core/domains/auth/services/Security.ts create mode 100644 src/core/domains/auth/services/SecurityReader.ts diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index f9a308de9..629edc47f 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -59,6 +59,25 @@ export default class User extends Model implements IUserModel { 'roles' ] + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasRole(roles: string | string[]): boolean { + roles = typeof roles === 'string' ? [roles] : roles; + const userRoles = this.getAttribute('roles') ?? []; + + for(const role of roles) { + if(userRoles.includes(role)) { + return true; + } + } + + return false + } + /** * @returns The tokens associated with this user * diff --git a/src/core/domains/auth/commands/GenerateJWTSecret.ts b/src/core/domains/auth/commands/GenerateJWTSecret.ts index 6e1cdc6e9..477fddec8 100644 --- a/src/core/domains/auth/commands/GenerateJWTSecret.ts +++ b/src/core/domains/auth/commands/GenerateJWTSecret.ts @@ -1,8 +1,7 @@ +import BaseCommand from "@src/core/domains/console/base/BaseCommand"; import { IEnvService } from "@src/core/interfaces/IEnvService"; import EnvService from "@src/core/services/EnvService"; -import BaseCommand from "../../console/base/BaseCommand"; - class GenerateJwtSecret extends BaseCommand { signature = 'auth:generate-jwt-secret'; diff --git a/src/core/domains/auth/exceptions/ForbiddenResourceError.ts b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts new file mode 100644 index 000000000..749d52745 --- /dev/null +++ b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts @@ -0,0 +1,8 @@ +export default class ForbiddenResourceError extends Error { + + constructor(message: string = 'You do not have permission to access this resource') { + super(message); + this.name = 'ForbiddenResourceError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index 694d4392f..fc50a13c6 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,8 +1,10 @@ /* eslint-disable no-unused-vars */ +import User from "@src/app/models/auth/User"; import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; +import { ISecurityMiddleware } from "@src/core/domains/auth/middleware/securityMiddleware"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import IService from "@src/core/interfaces/IService"; @@ -102,4 +104,18 @@ export interface IAuthService extends IService { * @memberof IAuthService */ getAuthRoutes(): IRoute[] | null; + + /** + * Returns the authenticated user. + * + * @returns {User | null} + */ + user(): User | null; + + /** + * Returns the security middleware + * + * @returns {ISecurityMiddleware} + */ + securityMiddleware(): ISecurityMiddleware; } diff --git a/src/core/domains/auth/interfaces/ISecurityRequest.ts b/src/core/domains/auth/interfaces/ISecurityRequest.ts new file mode 100644 index 000000000..0cad0c95a --- /dev/null +++ b/src/core/domains/auth/interfaces/ISecurityRequest.ts @@ -0,0 +1,7 @@ +import { Request } from 'express'; + +import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; + +export default interface ISecurityRequest extends Request { + security?: IdentifiableSecurityCallback[] +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IUserModel.ts b/src/core/domains/auth/interfaces/IUserModel.ts index a2dfa055a..90c12efd3 100644 --- a/src/core/domains/auth/interfaces/IUserModel.ts +++ b/src/core/domains/auth/interfaces/IUserModel.ts @@ -15,4 +15,5 @@ export interface IUserData extends IModelData { export default interface IUserModel extends IModel { tokens(...args: any[]): Promise; + hasRole(...args: any[]): any; } \ No newline at end of file diff --git a/src/core/domains/auth/middleware/securityMiddleware.ts b/src/core/domains/auth/middleware/securityMiddleware.ts new file mode 100644 index 000000000..6c37c5c84 --- /dev/null +++ b/src/core/domains/auth/middleware/securityMiddleware.ts @@ -0,0 +1,46 @@ +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; + +/** + * This middleware will check the security definition of the route and validate it. + * If the security definition is not valid, it will throw an UnauthorizedError. + * + * @param {{ route: IRoute }} - The route object + * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + */ +export const securityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + // Attach security to the request object + req.security = route?.security ?? []; + + // Check if the hasRole security has been defined and validate + const hasRoleSecurity = req.security?.find((security) => security.id === SecurityIdentifiers.HAS_ROLE); + + if(hasRoleSecurity && !hasRoleSecurity.callback()) { + responseError(req, res, new ForbiddenResourceError(), 403) + return; + } + + // Security passed + next(); + } + catch (error) { + if(error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if(error instanceof Error) { + responseError(req, res, error) + } + } +}; \ No newline at end of file diff --git a/src/core/domains/auth/providers/AuthProvider.ts b/src/core/domains/auth/providers/AuthProvider.ts index d4390b142..a355ce295 100644 --- a/src/core/domains/auth/providers/AuthProvider.ts +++ b/src/core/domains/auth/providers/AuthProvider.ts @@ -1,10 +1,9 @@ import authConfig from "@src/config/auth"; import BaseProvider from "@src/core/base/Provider"; +import GenerateJwtSecret from "@src/core/domains/auth/commands/GenerateJWTSecret"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { App } from "@src/core/services/App"; -import GenerateJwtSecret from "../commands/GenerateJWTSecret"; - export default class AuthProvider extends BaseProvider { /** diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index 821c26195..b4da482cf 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -1,3 +1,4 @@ +import User from '@src/app/models/auth/User'; import Service from '@src/core/base/Service'; import InvalidJWTSecret from '@src/core/domains/auth/exceptions/InvalidJWTSecret'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; @@ -10,11 +11,13 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; import { IJSonWebToken } from '@src/core/domains/auth/interfaces/IJSonWebToken'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; import IUserRepository from '@src/core/domains/auth/interfaces/IUserRepository'; +import { securityMiddleware } from '@src/core/domains/auth/middleware/securityMiddleware'; import authRoutes from '@src/core/domains/auth/routes/auth'; import comparePassword from '@src/core/domains/auth/utils/comparePassword'; import createJwt from '@src/core/domains/auth/utils/createJwt'; import decodeJwt from '@src/core/domains/auth/utils/decodeJwt'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { App } from '@src/core/services/App'; export default class AuthService extends Service implements IAuthService { @@ -163,4 +166,22 @@ export default class AuthService extends Service implements IAuthSe return routes; } + /** + * Returns the currently authenticated user from the request context. + * @returns The user model if the user is authenticated, or null if not. + */ + user(): User | null { + return App.getValue('user') ?? null; + } + + /** + * Returns the security middleware for the AuthService. + * + * @returns The middleware that will run security checks defined in the route. + * @memberof AuthService + */ + securityMiddleware() { + return securityMiddleware; + } + } \ No newline at end of file diff --git a/src/core/domains/auth/services/Security.ts b/src/core/domains/auth/services/Security.ts new file mode 100644 index 000000000..40ab63a93 --- /dev/null +++ b/src/core/domains/auth/services/Security.ts @@ -0,0 +1,119 @@ +import Singleton from "@src/core/base/Singleton"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; + +// eslint-disable-next-line no-unused-vars +export type SecurityCallback = (...args: any[]) => boolean; + +/** + * An interface for defining security callbacks with an identifier. + */ +export type IdentifiableSecurityCallback = { + // The identifier for the security callback. + id: string; + // The condition for when the security check should be executed. Defaults to 'always'. + when: string | null; + // The arguments for the security callback. + arguements?: Record; + // The security callback function. + callback: SecurityCallback; +} + +/** + * A list of security identifiers. + */ +export const SecurityIdentifiers = { + RESOURCE_OWNER: 'resourceOwner', + HAS_ROLE: 'hasRole', + CUSTOM: 'custom' +} as const; + +/** + * Security class with static methods for basic defining security callbacks. + */ +class Security extends Singleton { + + /** + * The condition for when the security check should be executed. + */ + public when: string = 'always'; + + /** + * Sets the condition for when the security check should be executed. + * + * @param condition - The condition value. If the value is 'always', the security check is always executed. + * @returns The Security class instance for chaining. + */ + public static when(condition: string): typeof Security { + this.getInstance().when = condition; + return this; + } + + /** + * Gets and then resets the condition for when the security check should be executed to always. + * @returns The when condition + */ + public static getWhenAndReset(): string { + const when = this.getInstance().when; + this.getInstance().when = 'always'; + return when; + } + + /** + * Checks if the currently logged in user is the owner of the given resource. + * + * @param attribute - The key of the resource attribute that should contain the user id. + * @returns A security callback that can be used in the security definition. + */ + public static resourceOwner(attribute: string = 'userId'): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.RESOURCE_OWNER, + when: Security.getWhenAndReset(), + arguements: { key: attribute }, + callback: (resource: IModel) => { + if(typeof resource.getAttribute !== 'function') { + throw new Error('Resource is not an instance of IModel'); + } + + return resource.getAttribute(attribute) === App.container('auth').user()?.getId() + } + } + } + + /** + * Checks if the currently logged in user has the given role. + * @param role The role to check. + * @returns A callback function to be used in the security definition. + */ + public static hasRole(roles: string | string[]): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.HAS_ROLE, + when: Security.getWhenAndReset(), + callback: () => { + const user = App.container('auth').user(); + return user?.hasRole(roles) ?? false + } + } + } + + /** + * Creates a custom security callback. + * + * @param identifier - The identifier for the security callback. + * @param callback - The callback to be executed to check the security. + * @param rest - The arguments for the security callback. + * @returns A callback function to be used in the security definition. + */ + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IdentifiableSecurityCallback { + return { + id: identifier, + when: Security.getWhenAndReset(), + callback: () => { + return callback(...rest) + } + } + } + +} + +export default Security \ No newline at end of file diff --git a/src/core/domains/auth/services/SecurityReader.ts b/src/core/domains/auth/services/SecurityReader.ts new file mode 100644 index 000000000..f3b5e01cb --- /dev/null +++ b/src/core/domains/auth/services/SecurityReader.ts @@ -0,0 +1,52 @@ + +import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +class SecurityReader { + + /** + * Finds a security callback in the security callbacks of the given route resource options. + * + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string): IdentifiableSecurityCallback | undefined { + return this.find(options.security ?? [], id, when); + } + + /** + * Finds a security callback from the security callbacks associated with the given request. + * + * @param req - The request object containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRequest(req: BaseRequest, id: string, when?: string): IdentifiableSecurityCallback | undefined { + return this.find(req.security ?? [], id, when); + } + + /** + * Finds a security callback in the given array of security callbacks. + * + * @param security - The array of security callbacks to search. + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The when condition to match. If not provided, the method will return the first match. + * @returns The security callback if found, or undefined if not found. + */ + public static find(security: IdentifiableSecurityCallback[], id: string, when?: string): IdentifiableSecurityCallback | undefined { + return security?.find(security => { + + const matchesWhenCondition = when !== 'always' && security.when === when; + + return security.id === id && matchesWhenCondition; + }); + } + +} + +export default SecurityReader \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 680ecde31..cbc637943 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -3,6 +3,7 @@ import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; + /** * Creates a new instance of the model * @@ -21,6 +22,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 1d88165da..21a94db7e 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,6 +1,10 @@ import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; @@ -15,6 +19,8 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.DESTROY); + const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -23,6 +29,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + responseError(req, res, new ForbiddenResourceError(), 401) + return; + } + await result.delete(); res.send({ success: true }) @@ -30,9 +41,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 7e5e64085..a465d5930 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,9 +1,24 @@ import Repository from '@src/core/base/Repository'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; +import IModelData from '@src/core/interfaces/IModelData'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; +/** + * Formats the results by excluding guarded properties + * + * @param results + * @returns + */ +const formatResults = (results: IModel[]) => results.map(result => result.getData({ excludeGuarded: true }) as IModel); + /** * Finds all records in the resource's repository * @@ -13,11 +28,48 @@ import { Response } from 'express'; * @returns {Promise} */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { - - const repository = new Repository(options.resource); + try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.ALL) + + const repository = new Repository(options.resource); + + let results: IModel[] = []; + + /** + * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user + */ + if (resourceOwnerSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = App.container('auth').user()?.getId(); + + if (!userId) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + + if (typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + results = await repository.findMany({ [propertyKey]: userId }) + + res.send(formatResults(results)) + return; + } + + /** + * Finds all results without any restrictions + */ + results = await repository.findMany(); - let results = await repository.findMany(); - results = results.map(result => result.getData({ excludeGuarded : true }) as IModel); + res.send(formatResults(results)) + } + catch (err) { + if (err instanceof Error) { + responseError(req, res, err) + return; + } - res.send(results) + res.status(500).send({ error: 'Something went wrong' }) + } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 5a0b4f2c7..8c407bfb7 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,9 +1,14 @@ import Repository from '@src/core/base/Repository'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; /** @@ -16,9 +21,45 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.SHOW); + const repository = new Repository(options.resource); - const result = await repository.findById(req.params?.id); + let result: IModel | null = null; + + /** + * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user + */ + if(resourceOwnerSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = App.container('auth').user()?.getId(); + + if(!userId) { + responseError(req, res, new UnauthorizedError()); + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + result = await repository.findOne({ + id: req.params?.id, + [propertyKey]: userId + }) + + if (!result) { + throw new ModelNotFound('Resource not found'); + } + + res.send(result?.getData({ excludeGuarded: true }) as IModel); + + return; + } + + /** + * Find resource without restrictions + */ + result = await repository.findById(req.params?.id); if (!result) { throw new ModelNotFound('Resource not found'); @@ -29,9 +70,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index db0578c37..2f316a8ca 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,6 +1,10 @@ import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; @@ -16,6 +20,8 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.UPDATE); + const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -24,6 +30,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return; + } + result.fill(req.body); await result.save(); @@ -32,9 +43,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 9391eb82d..360880088 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -1,3 +1,4 @@ +import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; import { IRouteAction } from '@src/core/domains/express/interfaces/IRouteAction'; import { ValidatorCtor } from '@src/core/domains/validator/types/ValidatorCtor'; import { Middleware } from '@src/core/interfaces/Middleware.t'; @@ -10,4 +11,5 @@ export interface IRoute { middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; + security?: IdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 3d450a82a..742442534 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -1,6 +1,7 @@ +import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; +import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; @@ -11,4 +12,5 @@ export interface IRouteResourceOptions extends Pick { name: string; createValidator?: ValidatorCtor; updateValidator?: ValidatorCtor; + security?: IdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorize.ts b/src/core/domains/express/middleware/authorize.ts index b7b396624..763739aae 100644 --- a/src/core/domains/express/middleware/authorize.ts +++ b/src/core/domains/express/middleware/authorize.ts @@ -31,6 +31,8 @@ export const authorize = () => async (req: BaseRequest, res: Response, next: Nex req.user = user; req.apiToken = apiToken + App.setValue('user', user) + next(); } catch (error) { diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 0ca860571..46c7423aa 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -10,6 +10,17 @@ import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; +/** + * Resource types that can be utilized when adding Security to a route + */ +export const RouteResourceTypes = { + ALL: 'all', + SHOW: 'show', + CREATE: 'create', + UPDATE: 'update', + DESTROY: 'destroy' +} as const + /** * Returns a group of routes for a given resource * - name.index - GET - /name @@ -35,14 +46,18 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { name: `${name}.index`, method: 'get', path: `/${name}`, - action: resourceAction(options, resourceIndex) + action: resourceAction(options, resourceIndex), + middlewares: options.middlewares, + security: options.security }), // Get resource by id Route({ name: `${name}.show`, method: 'get', path: `/${name}/:id`, - action: resourceAction(options, resourceShow) + action: resourceAction(options, resourceShow), + middlewares: options.middlewares, + security: options.security }), // Update resource by id Route({ @@ -50,14 +65,18 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), - validator: options.updateValidator + validator: options.updateValidator, + middlewares: options.middlewares, + security: options.security }), // Delete resource by id Route({ name: `${name}.destroy`, method: 'delete', path: `/${name}/:id`, - action: resourceAction(options, resourceDelete) + action: resourceAction(options, resourceDelete), + middlewares: options.middlewares, + security: options.security }), // Create resource Route({ @@ -65,7 +84,9 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), - validator: options.createValidator + validator: options.createValidator, + middlewares: options.middlewares, + security: options.security }) ]) diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 8975966d4..42a4c0772 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -114,6 +114,14 @@ export default class ExpressService extends Service implements I ); } + if(route?.security) { + const securityMiddleware = App.container('auth').securityMiddleware() + + middlewares.push( + securityMiddleware({ route }) + ) + } + return middlewares; } diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index f353c28b4..899c21f5e 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,8 +1,9 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; +import ISecurityRequest from "@src/core/domains/auth/interfaces/ISecurityRequest"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; /** * Extends the express Request object with auth and validator properties. */ -export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest; \ No newline at end of file +export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest; \ No newline at end of file diff --git a/src/core/domains/validator/base/BaseValidator.ts b/src/core/domains/validator/base/BaseValidator.ts index 550c964a0..ed662aeb3 100644 --- a/src/core/domains/validator/base/BaseValidator.ts +++ b/src/core/domains/validator/base/BaseValidator.ts @@ -107,6 +107,14 @@ abstract class BaseValidator

im } } + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): Joi.ValidationOptions { + return {} + } + } export default BaseValidator \ No newline at end of file diff --git a/src/core/domains/validator/interfaces/IValidator.ts b/src/core/domains/validator/interfaces/IValidator.ts index a1b649096..7a68c2b91 100644 --- a/src/core/domains/validator/interfaces/IValidator.ts +++ b/src/core/domains/validator/interfaces/IValidator.ts @@ -40,6 +40,12 @@ interface IValidator * @returns The validator instance. */ setErrorMessage(customMessages: Record): IValidator; + + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): ValidationOptions; } export default IValidator diff --git a/src/core/domains/validator/middleware/validateMiddleware.ts b/src/core/domains/validator/middleware/validateMiddleware.ts index 69997722c..baed03299 100644 --- a/src/core/domains/validator/middleware/validateMiddleware.ts +++ b/src/core/domains/validator/middleware/validateMiddleware.ts @@ -18,7 +18,13 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM req.validator = validator; if(validateBeforeAction) { - const result = await validator.validate(req.body); + const result = await validator.validate( + req.body, + { + stripUnknown: true, + ...validator.getJoiOptions() + } + ); if(!result.success) { res.send({ diff --git a/src/core/services/App.ts b/src/core/services/App.ts index 8b771f9d2..65ddd755c 100644 --- a/src/core/services/App.ts +++ b/src/core/services/App.ts @@ -18,6 +18,28 @@ export class App extends Singleton { */ public env!: string; + /** + * Global values + */ + protected values: Record = {}; + + /** + * Sets a value + * @param key The key of the value + * @param value The value to set + */ + public static setValue(key: string, value: unknown): void { + this.getInstance().values[key] = value; + } + + /** + * Gets a value + * @param key The key of the value to get + */ + public static getValue(key: string): T | undefined { + return this.getInstance().values[key] as T; + } + /** * Sets a container * @param name The name of the container From bc6e7c8e65925d3d712128dcc1a74759e1dc784b Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 16:38:01 +0100 Subject: [PATCH 02/76] Added Security to express routes (progress, pre-refactor) --- src/config/http.ts | 2 +- src/core/domains/auth/actions/create.ts | 1 + src/core/domains/auth/actions/getUser.ts | 1 + src/core/domains/auth/actions/login.ts | 1 + src/core/domains/auth/actions/revoke.ts | 1 + src/core/domains/auth/actions/update.ts | 1 + src/core/domains/auth/actions/user.ts | 1 + .../domains/auth/interfaces/IAuthService.ts | 16 --- .../auth/interfaces/IRequestIdentifiable.ts | 5 + .../auth/middleware/securityMiddleware.ts | 46 ------- src/core/domains/auth/routes/auth.ts | 9 +- .../auth/security/ResourceOwnerSecurity.ts | 29 +++++ .../auth/security/authorizedSecurity.ts | 20 +++ .../domains/auth/security/hasRoleSecurity.ts | 23 ++++ src/core/domains/auth/services/AuthRequest.ts | 40 ++++++ src/core/domains/auth/services/AuthService.ts | 68 ++++------ src/core/domains/auth/services/Security.ts | 120 +++++++++++++++--- .../domains/auth/services/SecurityReader.ts | 46 ++++++- .../domains/express/actions/resourceCreate.ts | 41 ++++++ .../domains/express/actions/resourceDelete.ts | 13 +- .../domains/express/actions/resourceIndex.ts | 21 ++- .../domains/express/actions/resourceShow.ts | 22 +++- .../domains/express/actions/resourceUpdate.ts | 20 ++- .../exceptions/MissingSecurityError.ts | 8 ++ src/core/domains/express/interfaces/IRoute.ts | 1 + .../domains/express/interfaces/ISecurity.ts | 3 + .../express/interfaces/ISecurityMiddleware.ts | 7 + .../{authorize.ts => authorizeMiddleware.ts} | 26 ++-- .../middleware/basicLoggerMiddleware.ts | 8 ++ .../middleware/endCurrentRequestMiddleware.ts | 21 +++ src/core/domains/express/middleware/logger.ts | 7 - .../express/middleware/requestIdMiddleware.ts | 41 ++++++ .../express/middleware/securityMiddleware.ts | 108 ++++++++++++++++ .../domains/express/routing/RouteResource.ts | 5 + .../express/services/CurrentRequest.ts | 53 ++++++++ .../express/services/ExpressService.ts | 10 +- .../domains/express/types/BaseRequest.t.ts | 4 +- .../domains/make/templates/Action.ts.template | 1 + .../make/templates/Middleware.ts.template | 1 + .../domains/make/templates/Model.ts.template | 2 +- .../middleware/validateMiddleware.ts | 1 + 41 files changed, 676 insertions(+), 178 deletions(-) create mode 100644 src/core/domains/auth/interfaces/IRequestIdentifiable.ts delete mode 100644 src/core/domains/auth/middleware/securityMiddleware.ts create mode 100644 src/core/domains/auth/security/ResourceOwnerSecurity.ts create mode 100644 src/core/domains/auth/security/authorizedSecurity.ts create mode 100644 src/core/domains/auth/security/hasRoleSecurity.ts create mode 100644 src/core/domains/auth/services/AuthRequest.ts create mode 100644 src/core/domains/express/exceptions/MissingSecurityError.ts create mode 100644 src/core/domains/express/interfaces/ISecurity.ts create mode 100644 src/core/domains/express/interfaces/ISecurityMiddleware.ts rename src/core/domains/express/middleware/{authorize.ts => authorizeMiddleware.ts} (64%) create mode 100644 src/core/domains/express/middleware/basicLoggerMiddleware.ts create mode 100644 src/core/domains/express/middleware/endCurrentRequestMiddleware.ts delete mode 100644 src/core/domains/express/middleware/logger.ts create mode 100644 src/core/domains/express/middleware/requestIdMiddleware.ts create mode 100644 src/core/domains/express/middleware/securityMiddleware.ts create mode 100644 src/core/domains/express/services/CurrentRequest.ts diff --git a/src/config/http.ts b/src/config/http.ts index d1695677b..08276574d 100644 --- a/src/config/http.ts +++ b/src/config/http.ts @@ -1,7 +1,7 @@ -import express from 'express'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; import bodyParser from 'body-parser'; +import express from 'express'; const config: IExpressConfig = { enabled: parseBooleanFromString(process.env.ENABLE_EXPRESS, 'true'), diff --git a/src/core/domains/auth/actions/create.ts b/src/core/domains/auth/actions/create.ts index beb8b3919..ee23b1e08 100644 --- a/src/core/domains/auth/actions/create.ts +++ b/src/core/domains/auth/actions/create.ts @@ -61,6 +61,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error); + return; } } diff --git a/src/core/domains/auth/actions/getUser.ts b/src/core/domains/auth/actions/getUser.ts index 706221717..e645cf88b 100644 --- a/src/core/domains/auth/actions/getUser.ts +++ b/src/core/domains/auth/actions/getUser.ts @@ -18,6 +18,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // If there is an error, send the error response if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/login.ts b/src/core/domains/auth/actions/login.ts index 99062bdd9..c7a2ca2d8 100644 --- a/src/core/domains/auth/actions/login.ts +++ b/src/core/domains/auth/actions/login.ts @@ -38,6 +38,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/revoke.ts b/src/core/domains/auth/actions/revoke.ts index 01a641e71..b5cbf93df 100644 --- a/src/core/domains/auth/actions/revoke.ts +++ b/src/core/domains/auth/actions/revoke.ts @@ -26,6 +26,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/update.ts b/src/core/domains/auth/actions/update.ts index 317655b76..f77acc69f 100644 --- a/src/core/domains/auth/actions/update.ts +++ b/src/core/domains/auth/actions/update.ts @@ -38,6 +38,7 @@ export default async (req: BaseRequest, res: Response) => { // If there is an error, send the error response if(error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/user.ts b/src/core/domains/auth/actions/user.ts index 7c014d094..c41b8d391 100644 --- a/src/core/domains/auth/actions/user.ts +++ b/src/core/domains/auth/actions/user.ts @@ -18,6 +18,7 @@ export default (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index fc50a13c6..694d4392f 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,10 +1,8 @@ /* eslint-disable no-unused-vars */ -import User from "@src/app/models/auth/User"; import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; -import { ISecurityMiddleware } from "@src/core/domains/auth/middleware/securityMiddleware"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import IService from "@src/core/interfaces/IService"; @@ -104,18 +102,4 @@ export interface IAuthService extends IService { * @memberof IAuthService */ getAuthRoutes(): IRoute[] | null; - - /** - * Returns the authenticated user. - * - * @returns {User | null} - */ - user(): User | null; - - /** - * Returns the security middleware - * - * @returns {ISecurityMiddleware} - */ - securityMiddleware(): ISecurityMiddleware; } diff --git a/src/core/domains/auth/interfaces/IRequestIdentifiable.ts b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts new file mode 100644 index 000000000..b959f395b --- /dev/null +++ b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export default interface IRequestIdentifiable extends Request { + id?: string; +} \ No newline at end of file diff --git a/src/core/domains/auth/middleware/securityMiddleware.ts b/src/core/domains/auth/middleware/securityMiddleware.ts deleted file mode 100644 index 6c37c5c84..000000000 --- a/src/core/domains/auth/middleware/securityMiddleware.ts +++ /dev/null @@ -1,46 +0,0 @@ -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { NextFunction, Response } from 'express'; - -import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; -import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; - -// eslint-disable-next-line no-unused-vars -export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; - -/** - * This middleware will check the security definition of the route and validate it. - * If the security definition is not valid, it will throw an UnauthorizedError. - * - * @param {{ route: IRoute }} - The route object - * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} - */ -export const securityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - try { - // Attach security to the request object - req.security = route?.security ?? []; - - // Check if the hasRole security has been defined and validate - const hasRoleSecurity = req.security?.find((security) => security.id === SecurityIdentifiers.HAS_ROLE); - - if(hasRoleSecurity && !hasRoleSecurity.callback()) { - responseError(req, res, new ForbiddenResourceError(), 403) - return; - } - - // Security passed - next(); - } - catch (error) { - if(error instanceof UnauthorizedError) { - responseError(req, res, error, 401) - return; - } - - if(error instanceof Error) { - responseError(req, res, error) - } - } -}; \ No newline at end of file diff --git a/src/core/domains/auth/routes/auth.ts b/src/core/domains/auth/routes/auth.ts index fbda9c686..da7340901 100644 --- a/src/core/domains/auth/routes/auth.ts +++ b/src/core/domains/auth/routes/auth.ts @@ -6,10 +6,11 @@ import user from "@src/core/domains/auth/actions/user"; import authConsts from "@src/core/domains/auth/consts/authConsts"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { authorize } from "@src/core/domains/express/middleware/authorize"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; +import { authorizeMiddleware } from "../../express/middleware/authorizeMiddleware"; + export const routes = (config: IAuthConfig): IRoute[] => { return RouteGroup([ Route({ @@ -31,7 +32,7 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'patch', path: '/auth/user', action: update, - middlewares: [authorize()], + middlewares: [authorizeMiddleware()], validator: config.validators.updateUser, validateBeforeAction: true }), @@ -40,14 +41,14 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'get', path: '/auth/user', action: user, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }), Route({ name: authConsts.routes.authRevoke, method: 'post', path: '/auth/revoke', action: revoke, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }) ]) } diff --git a/src/core/domains/auth/security/ResourceOwnerSecurity.ts b/src/core/domains/auth/security/ResourceOwnerSecurity.ts new file mode 100644 index 000000000..f101d5b5e --- /dev/null +++ b/src/core/domains/auth/security/ResourceOwnerSecurity.ts @@ -0,0 +1,29 @@ +import User from "@src/app/models/auth/User"; +import { IModel } from "@src/core/interfaces/IModel"; + +import CurrentRequest from "../../express/services/CurrentRequest"; +import { BaseRequest } from "../../express/types/BaseRequest.t"; + +/** + * Checks if the currently logged in user is the owner of the given resource. + * + * @param req - The request object + * @param resource - The resource object + * @param attribute - The attribute name that should contain the user id + * @returns True if the user is the resource owner, false otherwise + */ +const resourceOwnerSecurity = (req: BaseRequest, resource: IModel, attribute: string): boolean => { + const user = CurrentRequest.get(req, 'user'); + + if(!user) { + return false; + } + + if(typeof resource.getAttribute !== 'function') { + throw new Error('Resource is not an instance of IModel'); + } + + return resource.getAttribute(attribute) === user?.getId() +} + +export default resourceOwnerSecurity \ No newline at end of file diff --git a/src/core/domains/auth/security/authorizedSecurity.ts b/src/core/domains/auth/security/authorizedSecurity.ts new file mode 100644 index 000000000..b4517bc9d --- /dev/null +++ b/src/core/domains/auth/security/authorizedSecurity.ts @@ -0,0 +1,20 @@ + +import CurrentRequest from "../../express/services/CurrentRequest"; +import { BaseRequest } from "../../express/types/BaseRequest.t"; + + +/** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * @param req - The Express Request object + * @returns True if the user is logged in, false otherwise + */ +const authorizedSecurity = (req: BaseRequest): boolean => { + if(CurrentRequest.get(req, 'user')) { + return true; + } + + return false; +} + +export default authorizedSecurity \ No newline at end of file diff --git a/src/core/domains/auth/security/hasRoleSecurity.ts b/src/core/domains/auth/security/hasRoleSecurity.ts new file mode 100644 index 000000000..199539b7d --- /dev/null +++ b/src/core/domains/auth/security/hasRoleSecurity.ts @@ -0,0 +1,23 @@ +import User from "@src/app/models/auth/User"; + +import CurrentRequest from "../../express/services/CurrentRequest"; +import { BaseRequest } from "../../express/types/BaseRequest.t"; + +/** + * Checks if the currently logged in user has the given role(s). + * + * @param {BaseRequest} req - The Express Request object + * @param {string | string[]} roles - The role(s) to check + * @returns {boolean} True if the user has the role, false otherwise + */ +const hasRoleSecurity = (req: BaseRequest, roles: string | string[]): boolean => { + const user = CurrentRequest.get(req, 'user'); + + if(!user) { + return false; + } + + return user?.hasRole(roles) ?? false +} + +export default hasRoleSecurity \ No newline at end of file diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts new file mode 100644 index 000000000..62b4a8675 --- /dev/null +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -0,0 +1,40 @@ +import { App } from "@src/core/services/App"; + +import CurrentRequest from "../../express/services/CurrentRequest"; +import { BaseRequest } from "../../express/types/BaseRequest.t"; +import UnauthorizedError from "../exceptions/UnauthorizedError"; + +class AuthRequest { + + /** + * Attempts to authorize a request with a Bearer token. + * + * If successful, attaches the user and apiToken to the request. Sets the user in the App. + * + * @param req The request to authorize + * @returns The authorized request + * @throws UnauthorizedError if the token is invalid + */ + public static async attemptAuthorizeRequest(req: BaseRequest): Promise { + const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); + + const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) + + const user = await apiToken?.user() + + if(!user || !apiToken) { + throw new UnauthorizedError(); + } + + req.user = user; + req.apiToken = apiToken + + CurrentRequest.set(req, 'user', user); + CurrentRequest.set(req, 'userId', user?.getId()) + + return req; + } + +} + +export default AuthRequest \ No newline at end of file diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index b4da482cf..a77d9b814 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -1,4 +1,3 @@ -import User from '@src/app/models/auth/User'; import Service from '@src/core/base/Service'; import InvalidJWTSecret from '@src/core/domains/auth/exceptions/InvalidJWTSecret'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; @@ -8,16 +7,14 @@ import IApiTokenModel from '@src/core/domains/auth/interfaces/IApitokenModel'; import IApiTokenRepository from '@src/core/domains/auth/interfaces/IApiTokenRepository'; import { IAuthConfig } from '@src/core/domains/auth/interfaces/IAuthConfig'; import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; -import { IJSonWebToken } from '@src/core/domains/auth/interfaces/IJSonWebToken'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; import IUserRepository from '@src/core/domains/auth/interfaces/IUserRepository'; -import { securityMiddleware } from '@src/core/domains/auth/middleware/securityMiddleware'; import authRoutes from '@src/core/domains/auth/routes/auth'; import comparePassword from '@src/core/domains/auth/utils/comparePassword'; import createJwt from '@src/core/domains/auth/utils/createJwt'; import decodeJwt from '@src/core/domains/auth/utils/decodeJwt'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; -import { App } from '@src/core/services/App'; +import { JsonWebTokenError } from 'jsonwebtoken'; export default class AuthService extends Service implements IAuthService { @@ -30,7 +27,7 @@ export default class AuthService extends Service implements IAuthSe * Repository for accessing user data */ public userRepository: IUserRepository; - + /** * Repository for accessing api tokens */ @@ -51,7 +48,7 @@ export default class AuthService extends Service implements IAuthSe * Validate jwt secret */ private validateJwtSecret() { - if(!this.config.jwtSecret || this.config.jwtSecret === '') { + if (!this.config.jwtSecret || this.config.jwtSecret === '') { throw new InvalidJWTSecret(); } } @@ -66,7 +63,7 @@ export default class AuthService extends Service implements IAuthSe await apiToken.save(); return apiToken } - + /** * Creates a JWT from a user model * @param user @@ -83,7 +80,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ jwt(apiToken: IApiTokenModel): string { - if(!apiToken?.data?.userId) { + if (!apiToken?.data?.userId) { throw new Error('Invalid token'); } const payload = JWTTokenFactory.create(apiToken.data?.userId?.toString(), apiToken.data?.token); @@ -96,7 +93,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async revokeToken(apiToken: IApiTokenModel): Promise { - if(apiToken?.data?.revokedAt) { + if (apiToken?.data?.revokedAt) { return; } @@ -110,21 +107,30 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async attemptAuthenticateToken(token: string): Promise { - const decoded = decodeJwt(this.config.jwtSecret, token) as IJSonWebToken; + try { + const decoded = decodeJwt(this.config.jwtSecret, token); - const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) + const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) - if(!apiToken) { - throw new UnauthorizedError() - } + if (!apiToken) { + throw new UnauthorizedError() + } - const user = await this.userRepository.findById(decoded.uid) + const user = await this.userRepository.findById(decoded.uid) - if(!user) { - throw new UnauthorizedError() + if (!user) { + throw new UnauthorizedError() + } + + return apiToken + } + catch (err) { + if(err instanceof JsonWebTokenError) { + throw new UnauthorizedError() + } } - return apiToken + return null } /** @@ -136,11 +142,11 @@ export default class AuthService extends Service implements IAuthSe async attemptCredentials(email: string, password: string): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; - if(!user?.data?.id) { + if (!user?.data?.id) { throw new UnauthorizedError() } - if(user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { + if (user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { throw new UnauthorizedError() } @@ -153,35 +159,17 @@ export default class AuthService extends Service implements IAuthSe * @returns an array of IRoute objects, or null if auth routes are disabled */ getAuthRoutes(): IRoute[] | null { - if(!this.config.enableAuthRoutes) { + if (!this.config.enableAuthRoutes) { return null } const routes = authRoutes(this.config); - if(!this.config.enableAuthRoutesAllowCreate) { + if (!this.config.enableAuthRoutesAllowCreate) { return routes.filter((route) => route.name !== 'authCreate'); } return routes; } - /** - * Returns the currently authenticated user from the request context. - * @returns The user model if the user is authenticated, or null if not. - */ - user(): User | null { - return App.getValue('user') ?? null; - } - - /** - * Returns the security middleware for the AuthService. - * - * @returns The middleware that will run security checks defined in the route. - * @memberof AuthService - */ - securityMiddleware() { - return securityMiddleware; - } - } \ No newline at end of file diff --git a/src/core/domains/auth/services/Security.ts b/src/core/domains/auth/services/Security.ts index 40ab63a93..2a6014319 100644 --- a/src/core/domains/auth/services/Security.ts +++ b/src/core/domains/auth/services/Security.ts @@ -1,9 +1,13 @@ import Singleton from "@src/core/base/Singleton"; import { IModel } from "@src/core/interfaces/IModel"; -import { App } from "@src/core/services/App"; + +import { BaseRequest } from "../../express/types/BaseRequest.t"; +import resourceOwnerSecurity from "../security/ResourceOwnerSecurity"; +import authorizedSecurity from "../security/authorizedSecurity"; +import hasRoleSecurity from "../security/hasRoleSecurity"; // eslint-disable-next-line no-unused-vars -export type SecurityCallback = (...args: any[]) => boolean; +export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; /** * An interface for defining security callbacks with an identifier. @@ -12,7 +16,9 @@ export type IdentifiableSecurityCallback = { // The identifier for the security callback. id: string; // The condition for when the security check should be executed. Defaults to 'always'. - when: string | null; + when: string[] | null; + // The condition for when the security check should never be executed. + never: string[] | null; // The arguments for the security callback. arguements?: Record; // The security callback function. @@ -23,11 +29,17 @@ export type IdentifiableSecurityCallback = { * A list of security identifiers. */ export const SecurityIdentifiers = { + AUTHORIZATION: 'authorization', RESOURCE_OWNER: 'resourceOwner', HAS_ROLE: 'hasRole', CUSTOM: 'custom' } as const; +/** + * The default condition for when the security check should be executed. + */ +export const ALWAYS = 'always'; + /** * Security class with static methods for basic defining security callbacks. */ @@ -36,7 +48,12 @@ class Security extends Singleton { /** * The condition for when the security check should be executed. */ - public when: string = 'always'; + public when: string[] | null = null; + + /** + * The condition for when the security check should never be executed. + */ + public never: string[] | null = null; /** * Sets the condition for when the security check should be executed. @@ -44,21 +61,44 @@ class Security extends Singleton { * @param condition - The condition value. If the value is 'always', the security check is always executed. * @returns The Security class instance for chaining. */ - public static when(condition: string): typeof Security { + public static when(condition: string | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; this.getInstance().when = condition; return this; } + /** + * Sets the condition for when the security check should never be executed. + * + * @param condition - The condition value(s) to set. If the value is 'always', the security check is never executed. + * @returns The Security class instance for chaining. + */ + public static never(condition: string | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; + this.getInstance().never = condition; + return this; + } + /** * Gets and then resets the condition for when the security check should be executed to always. * @returns The when condition */ - public static getWhenAndReset(): string { + public static getWhenAndReset(): string[] | null { const when = this.getInstance().when; - this.getInstance().when = 'always'; + this.getInstance().when = null; return when; } + /** + * Gets and then resets the condition for when the security check should never be executed. + * @returns The when condition + */ + public static getNeverAndReset(): string[] | null { + const never = this.getInstance().never; + this.getInstance().never = null; + return never; + } + /** * Checks if the currently logged in user is the owner of the given resource. * @@ -69,14 +109,57 @@ class Security extends Singleton { return { id: SecurityIdentifiers.RESOURCE_OWNER, when: Security.getWhenAndReset(), + never: Security.getNeverAndReset(), arguements: { key: attribute }, - callback: (resource: IModel) => { - if(typeof resource.getAttribute !== 'function') { - throw new Error('Resource is not an instance of IModel'); - } + callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) + } + } - return resource.getAttribute(attribute) === App.container('auth').user()?.getId() - } + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * Authorization failure does not throw any exceptions, this method allows the middleware to pass regarldess of authentication failure. + * This will allow the user to have full control over the unathenticated flow. + * + * Example: + * const authorizationSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, [ALWAYS]); + * + * if(authorizationSecurity && !authorizationSecurity.callback(req)) { + * responseError(req, res, new UnauthorizedError(), 401) + * return; + * } + * + * // Continue processing + * + * @returns A security callback that can be used in the security definition. + */ + public static authorized(): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.AUTHORIZATION, + when: Security.getWhenAndReset(), + never: Security.getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: false + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + } + } + + /** + * Same as `authorization` but throws an exception if the user is not authenticated. + * This method is useful if you want to handle authentication failure in a centralized way. + * + * @returns A security callback that can be used in the security definition. + */ + public static authorizationThrowsException(): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.AUTHORIZATION, + when: Security.getWhenAndReset(), + never: Security.getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) } } @@ -89,10 +172,8 @@ class Security extends Singleton { return { id: SecurityIdentifiers.HAS_ROLE, when: Security.getWhenAndReset(), - callback: () => { - const user = App.container('auth').user(); - return user?.hasRole(roles) ?? false - } + never: Security.getNeverAndReset(), + callback: (req: BaseRequest) => hasRoleSecurity(req, roles) } } @@ -107,9 +188,10 @@ class Security extends Singleton { public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IdentifiableSecurityCallback { return { id: identifier, + never: Security.getNeverAndReset(), when: Security.getWhenAndReset(), - callback: () => { - return callback(...rest) + callback: (req: BaseRequest, ...rest: any[]) => { + return callback(req, ...rest) } } } diff --git a/src/core/domains/auth/services/SecurityReader.ts b/src/core/domains/auth/services/SecurityReader.ts index f3b5e01cb..e79ec4676 100644 --- a/src/core/domains/auth/services/SecurityReader.ts +++ b/src/core/domains/auth/services/SecurityReader.ts @@ -1,5 +1,5 @@ -import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; +import { ALWAYS, IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; @@ -13,7 +13,7 @@ class SecurityReader { * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. * @returns The found security callback, or undefined if not found. */ - public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string): IdentifiableSecurityCallback | undefined { + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { return this.find(options.security ?? [], id, when); } @@ -25,7 +25,7 @@ class SecurityReader { * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. * @returns The found security callback, or undefined if not found. */ - public static findFromRequest(req: BaseRequest, id: string, when?: string): IdentifiableSecurityCallback | undefined { + public static findFromRequest(req: BaseRequest, id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { return this.find(req.security ?? [], id, when); } @@ -38,12 +38,44 @@ class SecurityReader { * @param when - The when condition to match. If not provided, the method will return the first match. * @returns The security callback if found, or undefined if not found. */ - public static find(security: IdentifiableSecurityCallback[], id: string, when?: string): IdentifiableSecurityCallback | undefined { - return security?.find(security => { + public static find(security: IdentifiableSecurityCallback[], id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { + when = when ?? null; + when = when && typeof when === 'string' ? [when] : when; + + // Checks if the condition should never be passable + const conditionNeverPassable = (conditions: string[] | null, never: string[] | null = null) => { + if(!never) return false; + + for(const neverCondition of never) { + if(conditions?.includes(neverCondition)) return true; + } + + return false; + } + + // Checks if the condition should be passable + const conditionPassable = (condition: string[] | null) => { + if(!condition) { + return true; + } - const matchesWhenCondition = when !== 'always' && security.when === when; + condition = typeof condition === 'string' ? [condition] : condition; - return security.id === id && matchesWhenCondition; + if(when?.includes(ALWAYS)) return true; + + for(const conditionString of condition) { + if(when?.includes(conditionString)) { + return true; + } + } + + return false; + } + + return security?.find(security => { + return security.id === id && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); }); } diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index cbc637943..cf07a93d9 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,8 +1,16 @@ +import User from '@src/app/models/auth/User'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; +import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; +import { ALWAYS, SecurityIdentifiers } from '../../auth/services/Security'; +import SecurityReader from '../../auth/services/SecurityReader'; +import MissingSecurityError from '../exceptions/MissingSecurityError'; +import { RouteResourceTypes } from '../routing/RouteResource'; +import CurrentRequest from '../services/CurrentRequest'; + /** * Creates a new instance of the model @@ -14,7 +22,40 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.CREATE]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.CREATE, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + const modelInstance = new options.resource(req.body); + + /** + * When a resourceOwnerSecurity is defined, we need to set the record that is owned by the user + */ + if(resourceOwnerSecurity) { + + if(!authorizationSecurity) { + responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); + } + + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = CurrentRequest.get(req, 'user')?.getId() + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + if(!userId) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + + modelInstance.setAttribute(propertyKey, userId) + } + await modelInstance.save(); res.status(201).send(modelInstance.getData({ excludeGuarded: true }) as IRouteResourceOptions['resource']) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 21a94db7e..ae84490fe 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,6 +1,6 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; @@ -9,6 +9,8 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; +import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; + /** * Deletes a resource * @@ -19,8 +21,13 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.DESTROY); + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.DESTROY]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.DESTROY, ALWAYS]); + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -29,7 +36,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } - if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { responseError(req, res, new ForbiddenResourceError(), 401) return; } diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index a465d5930..58df4ef78 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,6 +1,7 @@ +import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; @@ -8,9 +9,10 @@ import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResou import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; import IModelData from '@src/core/interfaces/IModelData'; -import { App } from '@src/core/services/App'; import { Response } from 'express'; +import CurrentRequest from '../services/CurrentRequest'; + /** * Formats the results by excluding guarded properties * @@ -29,7 +31,15 @@ const formatResults = (results: IModel[]) => results.map(result => r */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.ALL) + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.ALL, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + + console.log('resourceIndex CurrentRequest', CurrentRequest.getInstance()) const repository = new Repository(options.resource); @@ -38,9 +48,10 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user */ - if (resourceOwnerSecurity) { + if (resourceOwnerSecurity && authorizationSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('auth').user()?.getId(); + const userId = CurrentRequest.get(req, 'user')?.getId() if (!userId) { responseError(req, res, new UnauthorizedError(), 401); diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 8c407bfb7..7bbe903df 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,6 +1,7 @@ +import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; @@ -8,9 +9,11 @@ import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResou import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; -import { App } from '@src/core/services/App'; import { Response } from 'express'; +import ForbiddenResourceError from '../../auth/exceptions/ForbiddenResourceError'; +import CurrentRequest from '../services/CurrentRequest'; + /** * Finds a resource by id * @@ -21,8 +24,13 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.SHOW); + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.SHOW]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.SHOW, ALWAYS]); + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); let result: IModel | null = null; @@ -30,12 +38,14 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user */ - if(resourceOwnerSecurity) { + if(resourceOwnerSecurity && authorizationSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('auth').user()?.getId(); + const userId = CurrentRequest.get(req, 'user')?.getId(); if(!userId) { - responseError(req, res, new UnauthorizedError()); + responseError(req, res, new ForbiddenResourceError(), 403); + return; } if(typeof propertyKey !== 'string') { diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 2f316a8ca..8efc69a16 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,6 +1,6 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; @@ -10,6 +10,9 @@ import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; +import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; +import MissingSecurityError from '../exceptions/MissingSecurityError'; + /** * Updates a resource * @@ -20,8 +23,13 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.UPDATE); - + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.UPDATE]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.UPDATE, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -30,7 +38,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } - if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + if(resourceOwnerSecurity && !authorizationSecurity) { + responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); + } + + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { responseError(req, res, new ForbiddenResourceError(), 403) return; } diff --git a/src/core/domains/express/exceptions/MissingSecurityError.ts b/src/core/domains/express/exceptions/MissingSecurityError.ts new file mode 100644 index 000000000..8ae527ff4 --- /dev/null +++ b/src/core/domains/express/exceptions/MissingSecurityError.ts @@ -0,0 +1,8 @@ +export default class MissingSecurityError extends Error { + + constructor(message: string = 'Missing security for this route') { + super(message); + this.name = 'MissingSecurityError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 360880088..0bbcb9a35 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -5,6 +5,7 @@ import { Middleware } from '@src/core/interfaces/Middleware.t'; export interface IRoute { name: string; + resourceType?: string; path: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; action: IRouteAction; diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts new file mode 100644 index 000000000..a0b1a96d4 --- /dev/null +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -0,0 +1,3 @@ +export interface ISecurityAuthorizeProps { + throwExceptionOnUnauthorized?: boolean +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurityMiddleware.ts b/src/core/domains/express/interfaces/ISecurityMiddleware.ts new file mode 100644 index 000000000..cb8d07e0f --- /dev/null +++ b/src/core/domains/express/interfaces/ISecurityMiddleware.ts @@ -0,0 +1,7 @@ +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorize.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts similarity index 64% rename from src/core/domains/express/middleware/authorize.ts rename to src/core/domains/express/middleware/authorizeMiddleware.ts index 763739aae..6ddd4bcc0 100644 --- a/src/core/domains/express/middleware/authorize.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -1,9 +1,10 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { App } from '@src/core/services/App'; import { NextFunction, Response } from 'express'; +import AuthRequest from '../../auth/services/AuthRequest'; + /** * Authorize middleware * @@ -16,23 +17,15 @@ import { NextFunction, Response } from 'express'; * @param {NextFunction} next - The next function * @returns {Promise} */ -export const authorize = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { +export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { try { - const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); - - const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) - - const user = await apiToken?.user() - - if(!user || !apiToken) { - throw new UnauthorizedError(); - } - - req.user = user; - req.apiToken = apiToken - - App.setValue('user', user) + // Authorize the request + // Parses the authorization header + // If successful, attaches the user and apiToken to the request + // and sets the user in the App + await AuthRequest.attemptAuthorizeRequest(req); + next(); } catch (error) { @@ -43,6 +36,7 @@ export const authorize = () => async (req: BaseRequest, res: Response, next: Nex if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts new file mode 100644 index 000000000..9dbbafa97 --- /dev/null +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -0,0 +1,8 @@ +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + + +export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + console.log('Request', `${req.method} ${req.url}`, 'headers: ', req.headers); + next(); +}; diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts new file mode 100644 index 000000000..f1db05daf --- /dev/null +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -0,0 +1,21 @@ +import { NextFunction, Response } from "express"; + +import CurrentRequest from "../services/CurrentRequest"; +import { BaseRequest } from "../types/BaseRequest.t"; + + +/** + * Middleware that ends the current request context and removes all associated values. + */ +const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { + + res.once('finish', () => { + console.log('CurrentReqest (before)', CurrentRequest.getInstance()) + CurrentRequest.end(req) + console.log('CurrentReqest (after)', CurrentRequest.getInstance()) + }) + + next() +} + +export default endCurrentRequestMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/logger.ts b/src/core/domains/express/middleware/logger.ts deleted file mode 100644 index 1620a55c7..000000000 --- a/src/core/domains/express/middleware/logger.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { NextFunction, Response } from 'express'; - -export const logger = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`); - next(); -}; diff --git a/src/core/domains/express/middleware/requestIdMiddleware.ts b/src/core/domains/express/middleware/requestIdMiddleware.ts new file mode 100644 index 000000000..6a6c9f428 --- /dev/null +++ b/src/core/domains/express/middleware/requestIdMiddleware.ts @@ -0,0 +1,41 @@ +import { generateUuidV4 } from "@src/core/util/uuid/generateUuidV4"; +import { NextFunction, Response } from "express"; + +import { BaseRequest } from "../types/BaseRequest.t"; + +type Props = { + // eslint-disable-next-line no-unused-vars + generator: (...args: any[]) => string; + setHeader: boolean; + headerName: string; +} + +const defaultProps: Props = { + generator: generateUuidV4, + setHeader: true, + headerName: 'X-Request-Id' +} + +/** + * Sets a request id on the request object and sets the response header if desired + * + * @param {Props} props - Options to configure the request id middleware + * @param {string} [props.generator=generateUuidV4] - Function to generate a request id + * @param {boolean} [props.setHeader=true] - If true, sets the response header with the request id + * @param {string} [props.headerName='X-Request-Id'] - Name of the response header to set + * @returns {import("express").RequestHandler} - The middleware function + */ +const requestIdMiddleware = ({ generator, setHeader, headerName }: Props = defaultProps) => (req: BaseRequest, res: Response, next: NextFunction) => { + const oldValue = req.get(headerName) + const id = oldValue ?? generator() + + if(setHeader) { + res.set(headerName, id) + } + + req.id = id + + next() +} + +export default requestIdMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts new file mode 100644 index 000000000..5bc32ca11 --- /dev/null +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -0,0 +1,108 @@ +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +import AuthRequest from '../../auth/services/AuthRequest'; +import SecurityReader from '../../auth/services/SecurityReader'; +import { IRoute } from '../interfaces/IRoute'; +import { ISecurityMiddleware } from '../interfaces/ISecurityMiddleware'; + +const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { + req.security = route.security ?? []; +} + + +/** + * Applies the authorization security check on the request. + */ +const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { + + const conditions = [ALWAYS] + + if(route.resourceType) { + conditions.push(route.resourceType) + } + + const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, conditions); + + if (authorizeSecurity) { + try { + req = await AuthRequest.attemptAuthorizeRequest(req); + + if(!authorizeSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + } + catch (err) { + if (err instanceof UnauthorizedError && authorizeSecurity.arguements?.throwExceptionOnUnauthorized) { + throw err; + } + + // Continue processing + } + } +} + +/** + * Checks if the hasRole security has been defined and validates it. + * If the hasRole security is defined and the validation fails, it will send a 403 response with a ForbiddenResourceError. + */ +const applyHasRoleSecurity = (req: BaseRequest, res: Response): void | null => { + // Check if the hasRole security has been defined and validate + const securityHasRole = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_ROLE); + + if (securityHasRole && !securityHasRole.callback(req)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return null; + } + +} + +/** + * This middleware will check the security definition of the route and validate it. + * If the security definition is not valid, it will throw an UnauthorizedError. + * + * @param {{ route: IRoute }} - The route object + * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + */ +export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + + /** + * Adds security rules to the Express Request + */ + bindSecurityToRequest(route, req); + + /** + * Authorizes the user, allow continue processing on failed + */ + await applyAuthorizeSecurity(route, req, res) + + /** + * Check if the authorized user passes the has role security + */ + if(applyHasRoleSecurity(req, res) === null) { + return; + } + + /** + * Security is OK, continue + */ + next(); + } + catch (error) { + if (error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if (error instanceof Error) { + responseError(req, res, error) + return; + } + } +}; \ No newline at end of file diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 46c7423aa..d7cfeae45 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -44,6 +44,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Get all resources Route({ name: `${name}.index`, + resourceType: RouteResourceTypes.ALL, method: 'get', path: `/${name}`, action: resourceAction(options, resourceIndex), @@ -53,6 +54,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Get resource by id Route({ name: `${name}.show`, + resourceType: RouteResourceTypes.SHOW, method: 'get', path: `/${name}/:id`, action: resourceAction(options, resourceShow), @@ -62,6 +64,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Update resource by id Route({ name: `${name}.update`, + resourceType: RouteResourceTypes.UPDATE, method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), @@ -72,6 +75,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Delete resource by id Route({ name: `${name}.destroy`, + resourceType: RouteResourceTypes.DESTROY, method: 'delete', path: `/${name}/:id`, action: resourceAction(options, resourceDelete), @@ -81,6 +85,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Create resource Route({ name: `${name}.create`, + resourceType: RouteResourceTypes.CREATE, method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts new file mode 100644 index 000000000..590f88873 --- /dev/null +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -0,0 +1,53 @@ +import Singleton from "@src/core/base/Singleton"; + +import { BaseRequest } from "../types/BaseRequest.t"; + +class CurrentRequest extends Singleton { + + protected values: Record> = {}; + + /** + * Sets a value in the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining + */ + public static set(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + const requestId = req.id as string; + + if(!this.getInstance().values[requestId]) { + this.getInstance().values[requestId] = {} + } + + this.getInstance().values[requestId][key] = value; + return this; + } + + /** + * Gets a value from the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public static get(req: BaseRequest, key: string): T | undefined { + const requestId = req.id as string; + return this.getInstance().values[requestId]?.[key] as T ?? undefined + } + + /** + * Ends the current request context and removes all associated values + * + * @param {BaseRequest} req - The Express Request object + * @returns {void} + */ + public static end(req: BaseRequest) { + const requestId = req.id as string; + delete this.getInstance().values[requestId]; + } + +} + +export default CurrentRequest \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 42a4c0772..0ffb770e6 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -6,6 +6,10 @@ import { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; +import endCurrentRequestMiddleware from '../middleware/endCurrentRequestMiddleware'; +import requestIdMiddleware from '../middleware/requestIdMiddleware'; +import { securityMiddleware } from '../middleware/securityMiddleware'; + /** * ExpressService class * Responsible for initializing and configuring ExpressJS @@ -36,6 +40,10 @@ export default class ExpressService extends Service implements I if (!this.config) { throw new Error('Config not provided'); } + + this.app.use(requestIdMiddleware()) + this.app.use(endCurrentRequestMiddleware()) + for (const middleware of this.config?.globalMiddlewares ?? []) { this.app.use(middleware); } @@ -115,8 +123,6 @@ export default class ExpressService extends Service implements I } if(route?.security) { - const securityMiddleware = App.container('auth').securityMiddleware() - middlewares.push( securityMiddleware({ route }) ) diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index 899c21f5e..5e45fdf3b 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -3,7 +3,9 @@ import ISecurityRequest from "@src/core/domains/auth/interfaces/ISecurityRequest import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; +import IRequestIdentifiable from "../../auth/interfaces/IRequestIdentifiable"; + /** * Extends the express Request object with auth and validator properties. */ -export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest; \ No newline at end of file +export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest & IRequestIdentifiable; \ No newline at end of file diff --git a/src/core/domains/make/templates/Action.ts.template b/src/core/domains/make/templates/Action.ts.template index 39004e93d..aa39e09cb 100644 --- a/src/core/domains/make/templates/Action.ts.template +++ b/src/core/domains/make/templates/Action.ts.template @@ -16,6 +16,7 @@ export const #name# = (req: BaseRequest, res: Response) => { catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } } \ No newline at end of file diff --git a/src/core/domains/make/templates/Middleware.ts.template b/src/core/domains/make/templates/Middleware.ts.template index 6141a8bb5..d516affbc 100644 --- a/src/core/domains/make/templates/Middleware.ts.template +++ b/src/core/domains/make/templates/Middleware.ts.template @@ -23,6 +23,7 @@ export const #name# = () => async (req: BaseRequest, res: Response, next: NextFu catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/domains/make/templates/Model.ts.template b/src/core/domains/make/templates/Model.ts.template index c80008b73..40c652a29 100644 --- a/src/core/domains/make/templates/Model.ts.template +++ b/src/core/domains/make/templates/Model.ts.template @@ -9,7 +9,7 @@ import IModelData from '@src/core/interfaces/IModelData'; * bar: number; * baz: boolean; */ -interface I#name#Data extends IModelData { +export interface I#name#Data extends IModelData { } diff --git a/src/core/domains/validator/middleware/validateMiddleware.ts b/src/core/domains/validator/middleware/validateMiddleware.ts index baed03299..659505c1a 100644 --- a/src/core/domains/validator/middleware/validateMiddleware.ts +++ b/src/core/domains/validator/middleware/validateMiddleware.ts @@ -40,6 +40,7 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file From 7e522afbb4b1983aab3f7f527f82c7499d12c5ca Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 16:45:59 +0100 Subject: [PATCH 03/76] Refactored files into Express Domain --- src/core/domains/express/actions/resourceCreate.ts | 4 ++-- src/core/domains/express/actions/resourceDelete.ts | 4 ++-- src/core/domains/express/actions/resourceIndex.ts | 4 ++-- src/core/domains/express/actions/resourceShow.ts | 4 ++-- src/core/domains/express/actions/resourceUpdate.ts | 4 ++-- .../domains/{auth => express}/interfaces/ISecurityRequest.ts | 0 src/core/domains/express/middleware/securityMiddleware.ts | 4 ++-- .../domains/{auth => express}/security/authorizedSecurity.ts | 4 ++-- .../domains/{auth => express}/security/hasRoleSecurity.ts | 4 ++-- .../security/resourceOwnerSecurity.ts} | 4 ++-- src/core/domains/{auth => express}/services/Security.ts | 4 ++-- src/core/domains/{auth => express}/services/SecurityReader.ts | 4 ++-- src/core/domains/express/types/BaseRequest.t.ts | 2 +- 13 files changed, 23 insertions(+), 23 deletions(-) rename src/core/domains/{auth => express}/interfaces/ISecurityRequest.ts (100%) rename src/core/domains/{auth => express}/security/authorizedSecurity.ts (73%) rename src/core/domains/{auth => express}/security/hasRoleSecurity.ts (81%) rename src/core/domains/{auth/security/ResourceOwnerSecurity.ts => express/security/resourceOwnerSecurity.ts} (86%) rename src/core/domains/{auth => express}/services/Security.ts (99%) rename src/core/domains/{auth => express}/services/SecurityReader.ts (97%) diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index cf07a93d9..364237299 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -5,11 +5,11 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; -import { ALWAYS, SecurityIdentifiers } from '../../auth/services/Security'; -import SecurityReader from '../../auth/services/SecurityReader'; import MissingSecurityError from '../exceptions/MissingSecurityError'; import { RouteResourceTypes } from '../routing/RouteResource'; import CurrentRequest from '../services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; /** diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index ae84490fe..d1003da2b 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,7 +1,5 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; -import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; @@ -10,6 +8,8 @@ import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; /** * Deletes a resource diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 58df4ef78..438bdb455 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,8 +1,6 @@ import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; -import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; @@ -12,6 +10,8 @@ import IModelData from '@src/core/interfaces/IModelData'; import { Response } from 'express'; import CurrentRequest from '../services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; /** * Formats the results by excluding guarded properties diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 7bbe903df..ad4610440 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,8 +1,6 @@ import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; -import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; @@ -13,6 +11,8 @@ import { Response } from 'express'; import ForbiddenResourceError from '../../auth/exceptions/ForbiddenResourceError'; import CurrentRequest from '../services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; /** * Finds a resource by id diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 8efc69a16..b81283383 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,7 +1,5 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; -import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; @@ -12,6 +10,8 @@ import { Response } from 'express'; import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; import MissingSecurityError from '../exceptions/MissingSecurityError'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; /** * Updates a resource diff --git a/src/core/domains/auth/interfaces/ISecurityRequest.ts b/src/core/domains/express/interfaces/ISecurityRequest.ts similarity index 100% rename from src/core/domains/auth/interfaces/ISecurityRequest.ts rename to src/core/domains/express/interfaces/ISecurityRequest.ts diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index 5bc32ca11..f4132c60e 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -1,14 +1,14 @@ import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; import AuthRequest from '../../auth/services/AuthRequest'; -import SecurityReader from '../../auth/services/SecurityReader'; import { IRoute } from '../interfaces/IRoute'; import { ISecurityMiddleware } from '../interfaces/ISecurityMiddleware'; +import { ALWAYS, SecurityIdentifiers } from '../services/Security'; +import SecurityReader from '../services/SecurityReader'; const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { req.security = route.security ?? []; diff --git a/src/core/domains/auth/security/authorizedSecurity.ts b/src/core/domains/express/security/authorizedSecurity.ts similarity index 73% rename from src/core/domains/auth/security/authorizedSecurity.ts rename to src/core/domains/express/security/authorizedSecurity.ts index b4517bc9d..82eb98751 100644 --- a/src/core/domains/auth/security/authorizedSecurity.ts +++ b/src/core/domains/express/security/authorizedSecurity.ts @@ -1,6 +1,6 @@ -import CurrentRequest from "../../express/services/CurrentRequest"; -import { BaseRequest } from "../../express/types/BaseRequest.t"; +import CurrentRequest from "../services/CurrentRequest"; +import { BaseRequest } from "../types/BaseRequest.t"; /** diff --git a/src/core/domains/auth/security/hasRoleSecurity.ts b/src/core/domains/express/security/hasRoleSecurity.ts similarity index 81% rename from src/core/domains/auth/security/hasRoleSecurity.ts rename to src/core/domains/express/security/hasRoleSecurity.ts index 199539b7d..50d962a76 100644 --- a/src/core/domains/auth/security/hasRoleSecurity.ts +++ b/src/core/domains/express/security/hasRoleSecurity.ts @@ -1,7 +1,7 @@ import User from "@src/app/models/auth/User"; -import CurrentRequest from "../../express/services/CurrentRequest"; -import { BaseRequest } from "../../express/types/BaseRequest.t"; +import CurrentRequest from "../services/CurrentRequest"; +import { BaseRequest } from "../types/BaseRequest.t"; /** * Checks if the currently logged in user has the given role(s). diff --git a/src/core/domains/auth/security/ResourceOwnerSecurity.ts b/src/core/domains/express/security/resourceOwnerSecurity.ts similarity index 86% rename from src/core/domains/auth/security/ResourceOwnerSecurity.ts rename to src/core/domains/express/security/resourceOwnerSecurity.ts index f101d5b5e..e8d516c39 100644 --- a/src/core/domains/auth/security/ResourceOwnerSecurity.ts +++ b/src/core/domains/express/security/resourceOwnerSecurity.ts @@ -1,8 +1,8 @@ import User from "@src/app/models/auth/User"; import { IModel } from "@src/core/interfaces/IModel"; -import CurrentRequest from "../../express/services/CurrentRequest"; -import { BaseRequest } from "../../express/types/BaseRequest.t"; +import CurrentRequest from "../services/CurrentRequest"; +import { BaseRequest } from "../types/BaseRequest.t"; /** * Checks if the currently logged in user is the owner of the given resource. diff --git a/src/core/domains/auth/services/Security.ts b/src/core/domains/express/services/Security.ts similarity index 99% rename from src/core/domains/auth/services/Security.ts rename to src/core/domains/express/services/Security.ts index 2a6014319..13b90525a 100644 --- a/src/core/domains/auth/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -1,10 +1,10 @@ import Singleton from "@src/core/base/Singleton"; import { IModel } from "@src/core/interfaces/IModel"; -import { BaseRequest } from "../../express/types/BaseRequest.t"; -import resourceOwnerSecurity from "../security/ResourceOwnerSecurity"; import authorizedSecurity from "../security/authorizedSecurity"; import hasRoleSecurity from "../security/hasRoleSecurity"; +import resourceOwnerSecurity from "../security/ResourceOwnerSecurity"; +import { BaseRequest } from "../types/BaseRequest.t"; // eslint-disable-next-line no-unused-vars export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; diff --git a/src/core/domains/auth/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts similarity index 97% rename from src/core/domains/auth/services/SecurityReader.ts rename to src/core/domains/express/services/SecurityReader.ts index e79ec4676..9d73d0522 100644 --- a/src/core/domains/auth/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,8 +1,8 @@ - -import { ALWAYS, IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { ALWAYS, IdentifiableSecurityCallback } from "./Security"; + class SecurityReader { /** diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index 5e45fdf3b..765b9e6b5 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,9 +1,9 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; -import ISecurityRequest from "@src/core/domains/auth/interfaces/ISecurityRequest"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; import IRequestIdentifiable from "../../auth/interfaces/IRequestIdentifiable"; +import ISecurityRequest from "../interfaces/ISecurityRequest"; /** * Extends the express Request object with auth and validator properties. From 6010f28f2ad8d5f4618740ac31e015a745ac7289 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 16:48:41 +0100 Subject: [PATCH 04/76] Fixed imports, eslint --- src/core/domains/auth/routes/auth.ts | 3 +-- src/core/domains/auth/services/AuthRequest.ts | 7 +++---- src/core/domains/express/actions/resourceCreate.ts | 13 ++++++------- src/core/domains/express/actions/resourceDelete.ts | 7 +++---- src/core/domains/express/actions/resourceIndex.ts | 7 +++---- src/core/domains/express/actions/resourceShow.ts | 9 ++++----- src/core/domains/express/actions/resourceUpdate.ts | 9 ++++----- .../domains/express/interfaces/ISecurityRequest.ts | 1 - .../express/middleware/authorizeMiddleware.ts | 3 +-- .../middleware/endCurrentRequestMiddleware.ts | 5 ++--- .../express/middleware/requestIdMiddleware.ts | 3 +-- .../express/middleware/securityMiddleware.ts | 11 +++++------ .../domains/express/security/authorizedSecurity.ts | 4 ++-- .../domains/express/security/hasRoleSecurity.ts | 5 ++--- .../express/security/resourceOwnerSecurity.ts | 5 ++--- src/core/domains/express/services/CurrentRequest.ts | 3 +-- src/core/domains/express/services/ExpressService.ts | 7 +++---- src/core/domains/express/services/Security.ts | 9 ++++----- src/core/domains/express/services/SecurityReader.ts | 3 +-- src/core/domains/express/types/BaseRequest.t.ts | 5 ++--- 20 files changed, 50 insertions(+), 69 deletions(-) diff --git a/src/core/domains/auth/routes/auth.ts b/src/core/domains/auth/routes/auth.ts index da7340901..4549c77e5 100644 --- a/src/core/domains/auth/routes/auth.ts +++ b/src/core/domains/auth/routes/auth.ts @@ -6,11 +6,10 @@ import user from "@src/core/domains/auth/actions/user"; import authConsts from "@src/core/domains/auth/consts/authConsts"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; +import { authorizeMiddleware } from "@src/core/domains/express/middleware/authorizeMiddleware"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; -import { authorizeMiddleware } from "../../express/middleware/authorizeMiddleware"; - export const routes = (config: IAuthConfig): IRoute[] => { return RouteGroup([ Route({ diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts index 62b4a8675..8d3976aae 100644 --- a/src/core/domains/auth/services/AuthRequest.ts +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -1,9 +1,8 @@ +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { App } from "@src/core/services/App"; -import CurrentRequest from "../../express/services/CurrentRequest"; -import { BaseRequest } from "../../express/types/BaseRequest.t"; -import UnauthorizedError from "../exceptions/UnauthorizedError"; - class AuthRequest { /** diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 364237299..f51e2ff54 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,16 +1,15 @@ import User from '@src/app/models/auth/User'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; -import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; -import MissingSecurityError from '../exceptions/MissingSecurityError'; -import { RouteResourceTypes } from '../routing/RouteResource'; -import CurrentRequest from '../services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - /** * Creates a new instance of the model diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index d1003da2b..3ccd8a376 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,16 +1,15 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; -import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - /** * Deletes a resource * diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 438bdb455..42a483d6e 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -4,15 +4,14 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedErr import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; import IModelData from '@src/core/interfaces/IModelData'; import { Response } from 'express'; -import CurrentRequest from '../services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - /** * Formats the results by excluding guarded properties * diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index ad4610440..66eed7e3f 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,19 +1,18 @@ import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; -import ForbiddenResourceError from '../../auth/exceptions/ForbiddenResourceError'; -import CurrentRequest from '../services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - /** * Finds a resource by id * diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index b81283383..6b1f9b976 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,18 +1,17 @@ import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; -import UnauthorizedError from '../../auth/exceptions/UnauthorizedError'; -import MissingSecurityError from '../exceptions/MissingSecurityError'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - /** * Updates a resource * diff --git a/src/core/domains/express/interfaces/ISecurityRequest.ts b/src/core/domains/express/interfaces/ISecurityRequest.ts index 0cad0c95a..d32a06ba4 100644 --- a/src/core/domains/express/interfaces/ISecurityRequest.ts +++ b/src/core/domains/express/interfaces/ISecurityRequest.ts @@ -1,5 +1,4 @@ import { Request } from 'express'; - import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; export default interface ISecurityRequest extends Request { diff --git a/src/core/domains/express/middleware/authorizeMiddleware.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts index 6ddd4bcc0..1283abc77 100644 --- a/src/core/domains/express/middleware/authorizeMiddleware.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -1,10 +1,9 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; -import AuthRequest from '../../auth/services/AuthRequest'; - /** * Authorize middleware * diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts index f1db05daf..f62b29e75 100644 --- a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -1,8 +1,7 @@ +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { NextFunction, Response } from "express"; -import CurrentRequest from "../services/CurrentRequest"; -import { BaseRequest } from "../types/BaseRequest.t"; - /** * Middleware that ends the current request context and removes all associated values. diff --git a/src/core/domains/express/middleware/requestIdMiddleware.ts b/src/core/domains/express/middleware/requestIdMiddleware.ts index 6a6c9f428..df67c4fd2 100644 --- a/src/core/domains/express/middleware/requestIdMiddleware.ts +++ b/src/core/domains/express/middleware/requestIdMiddleware.ts @@ -1,8 +1,7 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { generateUuidV4 } from "@src/core/util/uuid/generateUuidV4"; import { NextFunction, Response } from "express"; -import { BaseRequest } from "../types/BaseRequest.t"; - type Props = { // eslint-disable-next-line no-unused-vars generator: (...args: any[]) => string; diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index f4132c60e..e06ee8edc 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -1,15 +1,14 @@ import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurityMiddleware'; import responseError from '@src/core/domains/express/requests/responseError'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; -import AuthRequest from '../../auth/services/AuthRequest'; -import { IRoute } from '../interfaces/IRoute'; -import { ISecurityMiddleware } from '../interfaces/ISecurityMiddleware'; -import { ALWAYS, SecurityIdentifiers } from '../services/Security'; -import SecurityReader from '../services/SecurityReader'; - const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { req.security = route.security ?? []; } diff --git a/src/core/domains/express/security/authorizedSecurity.ts b/src/core/domains/express/security/authorizedSecurity.ts index 82eb98751..bdbf0fcaa 100644 --- a/src/core/domains/express/security/authorizedSecurity.ts +++ b/src/core/domains/express/security/authorizedSecurity.ts @@ -1,6 +1,6 @@ -import CurrentRequest from "../services/CurrentRequest"; -import { BaseRequest } from "../types/BaseRequest.t"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; /** diff --git a/src/core/domains/express/security/hasRoleSecurity.ts b/src/core/domains/express/security/hasRoleSecurity.ts index 50d962a76..34f702327 100644 --- a/src/core/domains/express/security/hasRoleSecurity.ts +++ b/src/core/domains/express/security/hasRoleSecurity.ts @@ -1,7 +1,6 @@ import User from "@src/app/models/auth/User"; - -import CurrentRequest from "../services/CurrentRequest"; -import { BaseRequest } from "../types/BaseRequest.t"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; /** * Checks if the currently logged in user has the given role(s). diff --git a/src/core/domains/express/security/resourceOwnerSecurity.ts b/src/core/domains/express/security/resourceOwnerSecurity.ts index e8d516c39..5e606ca45 100644 --- a/src/core/domains/express/security/resourceOwnerSecurity.ts +++ b/src/core/domains/express/security/resourceOwnerSecurity.ts @@ -1,9 +1,8 @@ import User from "@src/app/models/auth/User"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from "@src/core/interfaces/IModel"; -import CurrentRequest from "../services/CurrentRequest"; -import { BaseRequest } from "../types/BaseRequest.t"; - /** * Checks if the currently logged in user is the owner of the given resource. * diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts index 590f88873..0e02edc97 100644 --- a/src/core/domains/express/services/CurrentRequest.ts +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -1,6 +1,5 @@ import Singleton from "@src/core/base/Singleton"; - -import { BaseRequest } from "../types/BaseRequest.t"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; class CurrentRequest extends Singleton { diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 0ffb770e6..ffe7a0e2a 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -2,14 +2,13 @@ import Service from '@src/core/base/Service'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import endCurrentRequestMiddleware from '@src/core/domains/express/middleware/endCurrentRequestMiddleware'; +import requestIdMiddleware from '@src/core/domains/express/middleware/requestIdMiddleware'; +import { securityMiddleware } from '@src/core/domains/express/middleware/securityMiddleware'; import { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; -import endCurrentRequestMiddleware from '../middleware/endCurrentRequestMiddleware'; -import requestIdMiddleware from '../middleware/requestIdMiddleware'; -import { securityMiddleware } from '../middleware/securityMiddleware'; - /** * ExpressService class * Responsible for initializing and configuring ExpressJS diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 13b90525a..528a766c5 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -1,11 +1,10 @@ import Singleton from "@src/core/base/Singleton"; +import authorizedSecurity from "@src/core/domains/express/security/authorizedSecurity"; +import hasRoleSecurity from "@src/core/domains/express/security/hasRoleSecurity"; +import resourceOwnerSecurity from "@src/core/domains/express/security/resourceOwnerSecurity"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from "@src/core/interfaces/IModel"; -import authorizedSecurity from "../security/authorizedSecurity"; -import hasRoleSecurity from "../security/hasRoleSecurity"; -import resourceOwnerSecurity from "../security/ResourceOwnerSecurity"; -import { BaseRequest } from "../types/BaseRequest.t"; - // eslint-disable-next-line no-unused-vars export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index 9d73d0522..f852c454e 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,8 +1,7 @@ import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { ALWAYS, IdentifiableSecurityCallback } from "@src/core/domains/express/services/Security"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { ALWAYS, IdentifiableSecurityCallback } from "./Security"; - class SecurityReader { /** diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index 765b9e6b5..c5514f6a0 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,10 +1,9 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; +import IRequestIdentifiable from "@src/core/domains/auth/interfaces/IRequestIdentifiable"; +import ISecurityRequest from "@src/core/domains/express/interfaces/ISecurityRequest"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; -import IRequestIdentifiable from "../../auth/interfaces/IRequestIdentifiable"; -import ISecurityRequest from "../interfaces/ISecurityRequest"; - /** * Extends the express Request object with auth and validator properties. */ From 2e66155d6a561db98e705ab4714ebbf85993fe81 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 22:14:45 +0100 Subject: [PATCH 05/76] Refactored security interfaces into single file --- .../domains/express/actions/resourceDelete.ts | 2 +- src/core/domains/express/interfaces/IRoute.ts | 4 +- .../domains/express/interfaces/ISecurity.ts | 39 +++++++++++++++++++ .../express/interfaces/ISecurityMiddleware.ts | 7 ---- .../express/interfaces/ISecurityRequest.ts | 6 --- .../express/middleware/securityMiddleware.ts | 2 +- src/core/domains/express/services/Security.ts | 31 ++++----------- .../express/services/SecurityReader.ts | 9 +++-- .../domains/express/types/BaseRequest.t.ts | 2 +- 9 files changed, 56 insertions(+), 46 deletions(-) delete mode 100644 src/core/domains/express/interfaces/ISecurityMiddleware.ts delete mode 100644 src/core/domains/express/interfaces/ISecurityRequest.ts diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 3ccd8a376..881423840 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -36,7 +36,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { - responseError(req, res, new ForbiddenResourceError(), 401) + responseError(req, res, new ForbiddenResourceError(), 403) return; } diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 0bbcb9a35..e3e41ed99 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -1,5 +1,5 @@ -import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; import { IRouteAction } from '@src/core/domains/express/interfaces/IRouteAction'; +import { IIdentifiableSecurityCallback } from '@src/core/domains/express/interfaces/ISecurity'; import { ValidatorCtor } from '@src/core/domains/validator/types/ValidatorCtor'; import { Middleware } from '@src/core/interfaces/Middleware.t'; @@ -12,5 +12,5 @@ export interface IRoute { middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; - security?: IdentifiableSecurityCallback[]; + security?: IIdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index a0b1a96d4..a9ba1bd6b 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -1,3 +1,42 @@ +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Request, Response } from 'express'; + +/** + * Authorize Security props + */ export interface ISecurityAuthorizeProps { throwExceptionOnUnauthorized?: boolean +} + +/** + * The callback function + */ +// eslint-disable-next-line no-unused-vars +export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; + +/** + * An interface for defining security callbacks with an identifier. + */ +export type IIdentifiableSecurityCallback = { + // The identifier for the security callback. + id: string; + // The condition for when the security check should be executed. Defaults to 'always'. + when: string[] | null; + // The condition for when the security check should never be executed. + never: string[] | null; + // The arguments for the security callback. + arguements?: Record; + // The security callback function. + callback: SecurityCallback; +} + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; + +/** + * Security request to be included in BaseRequest + */ +export default interface ISecurityRequest extends Request { + security?: IIdentifiableSecurityCallback[] } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurityMiddleware.ts b/src/core/domains/express/interfaces/ISecurityMiddleware.ts deleted file mode 100644 index cb8d07e0f..000000000 --- a/src/core/domains/express/interfaces/ISecurityMiddleware.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { NextFunction, Response } from 'express'; - - -// eslint-disable-next-line no-unused-vars -export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurityRequest.ts b/src/core/domains/express/interfaces/ISecurityRequest.ts deleted file mode 100644 index d32a06ba4..000000000 --- a/src/core/domains/express/interfaces/ISecurityRequest.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Request } from 'express'; -import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; - -export default interface ISecurityRequest extends Request { - security?: IdentifiableSecurityCallback[] -} \ No newline at end of file diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index e06ee8edc..2e356db48 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -2,7 +2,7 @@ import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenR import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; -import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurityMiddleware'; +import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurity'; import responseError from '@src/core/domains/express/requests/responseError'; import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 528a766c5..f409522f8 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -1,29 +1,11 @@ import Singleton from "@src/core/base/Singleton"; +import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import authorizedSecurity from "@src/core/domains/express/security/authorizedSecurity"; import hasRoleSecurity from "@src/core/domains/express/security/hasRoleSecurity"; import resourceOwnerSecurity from "@src/core/domains/express/security/resourceOwnerSecurity"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from "@src/core/interfaces/IModel"; -// eslint-disable-next-line no-unused-vars -export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; - -/** - * An interface for defining security callbacks with an identifier. - */ -export type IdentifiableSecurityCallback = { - // The identifier for the security callback. - id: string; - // The condition for when the security check should be executed. Defaults to 'always'. - when: string[] | null; - // The condition for when the security check should never be executed. - never: string[] | null; - // The arguments for the security callback. - arguements?: Record; - // The security callback function. - callback: SecurityCallback; -} - /** * A list of security identifiers. */ @@ -104,7 +86,7 @@ class Security extends Singleton { * @param attribute - The key of the resource attribute that should contain the user id. * @returns A security callback that can be used in the security definition. */ - public static resourceOwner(attribute: string = 'userId'): IdentifiableSecurityCallback { + public static resourceOwner(attribute: string = 'userId'): IIdentifiableSecurityCallback { return { id: SecurityIdentifiers.RESOURCE_OWNER, when: Security.getWhenAndReset(), @@ -132,7 +114,7 @@ class Security extends Singleton { * * @returns A security callback that can be used in the security definition. */ - public static authorized(): IdentifiableSecurityCallback { + public static authorized(): IIdentifiableSecurityCallback { return { id: SecurityIdentifiers.AUTHORIZATION, when: Security.getWhenAndReset(), @@ -150,7 +132,7 @@ class Security extends Singleton { * * @returns A security callback that can be used in the security definition. */ - public static authorizationThrowsException(): IdentifiableSecurityCallback { + public static authorizationThrowsException(): IIdentifiableSecurityCallback { return { id: SecurityIdentifiers.AUTHORIZATION, when: Security.getWhenAndReset(), @@ -167,7 +149,7 @@ class Security extends Singleton { * @param role The role to check. * @returns A callback function to be used in the security definition. */ - public static hasRole(roles: string | string[]): IdentifiableSecurityCallback { + public static hasRole(roles: string | string[]): IIdentifiableSecurityCallback { return { id: SecurityIdentifiers.HAS_ROLE, when: Security.getWhenAndReset(), @@ -184,7 +166,8 @@ class Security extends Singleton { * @param rest - The arguments for the security callback. * @returns A callback function to be used in the security definition. */ - public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IdentifiableSecurityCallback { + // eslint-disable-next-line no-unused-vars + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { return { id: identifier, never: Security.getNeverAndReset(), diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index f852c454e..6411740f8 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,5 +1,6 @@ import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; -import { ALWAYS, IdentifiableSecurityCallback } from "@src/core/domains/express/services/Security"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; +import { ALWAYS } from "@src/core/domains/express/services/Security"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; class SecurityReader { @@ -12,7 +13,7 @@ class SecurityReader { * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. * @returns The found security callback, or undefined if not found. */ - public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { return this.find(options.security ?? [], id, when); } @@ -24,7 +25,7 @@ class SecurityReader { * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. * @returns The found security callback, or undefined if not found. */ - public static findFromRequest(req: BaseRequest, id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { + public static findFromRequest(req: BaseRequest, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { return this.find(req.security ?? [], id, when); } @@ -37,7 +38,7 @@ class SecurityReader { * @param when - The when condition to match. If not provided, the method will return the first match. * @returns The security callback if found, or undefined if not found. */ - public static find(security: IdentifiableSecurityCallback[], id: string, when?: string[] | null): IdentifiableSecurityCallback | undefined { + public static find(security: IIdentifiableSecurityCallback[], id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { when = when ?? null; when = when && typeof when === 'string' ? [when] : when; diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index c5514f6a0..92b3bf5e6 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,6 +1,6 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; import IRequestIdentifiable from "@src/core/domains/auth/interfaces/IRequestIdentifiable"; -import ISecurityRequest from "@src/core/domains/express/interfaces/ISecurityRequest"; +import ISecurityRequest from "@src/core/domains/express/interfaces/ISecurity"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; From 923f08a6b2577fb5a420bdea7e742c1d86bed27d Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 22:19:27 +0100 Subject: [PATCH 06/76] Removed console logs --- src/core/domains/express/actions/resourceIndex.ts | 2 -- .../domains/express/middleware/basicLoggerMiddleware.ts | 3 ++- .../express/middleware/endCurrentRequestMiddleware.ts | 3 --- src/core/domains/express/services/CurrentRequest.ts | 7 ++++++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 42a483d6e..b669a113e 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -38,8 +38,6 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp return; } - console.log('resourceIndex CurrentRequest', CurrentRequest.getInstance()) - const repository = new Repository(options.resource); let results: IModel[] = []; diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts index 9dbbafa97..7b6372dbe 100644 --- a/src/core/domains/express/middleware/basicLoggerMiddleware.ts +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -1,8 +1,9 @@ +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`, 'headers: ', req.headers); + console.log('Request', `${req.method} ${req.url}`, 'Headers: ', req.headers, 'CurrentRequest: ', CurrentRequest.get(req)); next(); }; diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts index f62b29e75..ed4377d98 100644 --- a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -7,11 +7,8 @@ import { NextFunction, Response } from "express"; * Middleware that ends the current request context and removes all associated values. */ const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { - res.once('finish', () => { - console.log('CurrentReqest (before)', CurrentRequest.getInstance()) CurrentRequest.end(req) - console.log('CurrentReqest (after)', CurrentRequest.getInstance()) }) next() diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts index 0e02edc97..1ef0eed3d 100644 --- a/src/core/domains/express/services/CurrentRequest.ts +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -31,8 +31,13 @@ class CurrentRequest extends Singleton { * @param {string} key - The key of the value to retrieve * @returns {T | undefined} - The value associated with the key, or undefined if not found */ - public static get(req: BaseRequest, key: string): T | undefined { + public static get(req: BaseRequest, key?: string): T | undefined { const requestId = req.id as string; + + if(!key) { + return this.getInstance().values[requestId] as T ?? undefined; + } + return this.getInstance().values[requestId]?.[key] as T ?? undefined } From 9e7a116ac5045472a822d015a664ecf09c860a9c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 22:21:18 +0100 Subject: [PATCH 07/76] Updated comment --- src/core/domains/express/middleware/securityMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index 2e356db48..61e3bcc7f 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -77,7 +77,8 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req bindSecurityToRequest(route, req); /** - * Authorizes the user, allow continue processing on failed + * Authorizes the user + * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth */ await applyAuthorizeSecurity(route, req, res) From ee86906c68fcb6050e0631d713d1bdfc5c2448e5 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 22:51:54 +0100 Subject: [PATCH 08/76] CurrentRequest to only store userId and not entire User instance --- src/core/domains/auth/services/AuthRequest.ts | 1 - src/core/domains/express/actions/resourceCreate.ts | 3 +-- src/core/domains/express/actions/resourceIndex.ts | 3 +-- src/core/domains/express/actions/resourceShow.ts | 3 +-- src/core/domains/express/security/authorizedSecurity.ts | 2 +- src/core/domains/express/security/hasRoleSecurity.ts | 4 +--- src/core/domains/express/security/resourceOwnerSecurity.ts | 4 +--- 7 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts index 8d3976aae..61ce2d9a6 100644 --- a/src/core/domains/auth/services/AuthRequest.ts +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -28,7 +28,6 @@ class AuthRequest { req.user = user; req.apiToken = apiToken - CurrentRequest.set(req, 'user', user); CurrentRequest.set(req, 'userId', user?.getId()) return req; diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index f51e2ff54..bc0087b7f 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,4 +1,3 @@ -import User from '@src/app/models/auth/User'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; @@ -41,7 +40,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'user')?.getId() + const userId = CurrentRequest.get(req, 'userId'); if(typeof propertyKey !== 'string') { throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index b669a113e..d51f41452 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,4 +1,3 @@ -import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; @@ -48,7 +47,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if (resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'user')?.getId() + const userId = CurrentRequest.get(req, 'userId'); if (!userId) { responseError(req, res, new UnauthorizedError(), 401); diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 66eed7e3f..0b9bff15f 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,4 +1,3 @@ -import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; @@ -40,7 +39,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if(resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'user')?.getId(); + const userId = CurrentRequest.get(req, 'userId'); if(!userId) { responseError(req, res, new ForbiddenResourceError(), 403); diff --git a/src/core/domains/express/security/authorizedSecurity.ts b/src/core/domains/express/security/authorizedSecurity.ts index bdbf0fcaa..3d6a0c9f3 100644 --- a/src/core/domains/express/security/authorizedSecurity.ts +++ b/src/core/domains/express/security/authorizedSecurity.ts @@ -10,7 +10,7 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * @returns True if the user is logged in, false otherwise */ const authorizedSecurity = (req: BaseRequest): boolean => { - if(CurrentRequest.get(req, 'user')) { + if(CurrentRequest.get(req, 'userId')) { return true; } diff --git a/src/core/domains/express/security/hasRoleSecurity.ts b/src/core/domains/express/security/hasRoleSecurity.ts index 34f702327..ec1959b3a 100644 --- a/src/core/domains/express/security/hasRoleSecurity.ts +++ b/src/core/domains/express/security/hasRoleSecurity.ts @@ -1,5 +1,3 @@ -import User from "@src/app/models/auth/User"; -import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; /** @@ -10,7 +8,7 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * @returns {boolean} True if the user has the role, false otherwise */ const hasRoleSecurity = (req: BaseRequest, roles: string | string[]): boolean => { - const user = CurrentRequest.get(req, 'user'); + const user = req.user; if(!user) { return false; diff --git a/src/core/domains/express/security/resourceOwnerSecurity.ts b/src/core/domains/express/security/resourceOwnerSecurity.ts index 5e606ca45..87555b246 100644 --- a/src/core/domains/express/security/resourceOwnerSecurity.ts +++ b/src/core/domains/express/security/resourceOwnerSecurity.ts @@ -1,5 +1,3 @@ -import User from "@src/app/models/auth/User"; -import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from "@src/core/interfaces/IModel"; @@ -12,7 +10,7 @@ import { IModel } from "@src/core/interfaces/IModel"; * @returns True if the user is the resource owner, false otherwise */ const resourceOwnerSecurity = (req: BaseRequest, resource: IModel, attribute: string): boolean => { - const user = CurrentRequest.get(req, 'user'); + const user = req.user; if(!user) { return false; From 6e6eaef6bb9c7ac735d58b4a3c8c06ff93950401 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 23:07:29 +0100 Subject: [PATCH 09/76] Added scopes to authentication domain, updated authorizeMiddleware to allow for scope checking --- .../2024-09-06-create-api-token-table.ts | 3 +- src/app/models/auth/ApiToken.ts | 21 +++++++++++++ .../domains/auth/factory/apiTokenFactory.ts | 3 +- .../domains/auth/interfaces/IApitokenModel.ts | 5 ++- .../domains/auth/interfaces/IAuthService.ts | 6 ++-- src/core/domains/auth/services/AuthService.ts | 12 +++---- .../express/middleware/authorizeMiddleware.ts | 31 ++++++++++++++++++- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/app/migrations/2024-09-06-create-api-token-table.ts b/src/app/migrations/2024-09-06-create-api-token-table.ts index c59b82dab..e5a7f546c 100644 --- a/src/app/migrations/2024-09-06-create-api-token-table.ts +++ b/src/app/migrations/2024-09-06-create-api-token-table.ts @@ -1,6 +1,6 @@ +import ApiToken from "@src/app/models/auth/ApiToken"; import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -import ApiToken from "@src/app/models/auth/ApiToken"; export class CreateApiTokenMigration extends BaseMigration { @@ -17,6 +17,7 @@ export class CreateApiTokenMigration extends BaseMigration { await this.schema.createTable(this.table, { userId: DataTypes.STRING, token: DataTypes.STRING, + scopes: DataTypes.JSON, revokedAt: DataTypes.DATE }) } diff --git a/src/app/models/auth/ApiToken.ts b/src/app/models/auth/ApiToken.ts index 85d3d6076..708f65804 100644 --- a/src/app/models/auth/ApiToken.ts +++ b/src/app/models/auth/ApiToken.ts @@ -20,9 +20,14 @@ class ApiToken extends Model implements IApiTokenModel { public fields: string[] = [ 'userId', 'token', + 'scopes', 'revokedAt' ] + public json: string[] = [ + 'scopes' + ] + /** * Disable createdAt and updatedAt timestamps */ @@ -39,6 +44,22 @@ class ApiToken extends Model implements IApiTokenModel { }) } + /** + * Checks if the given scope(s) are present in the scopes of this ApiToken + * @param scopes The scope(s) to check + * @returns True if all scopes are present, false otherwise + */ + public hasScope(scopes: string | string[]): boolean { + const currentScopes = this.getAttribute('scopes') ?? []; + scopes = typeof scopes === 'string' ? [scopes] : scopes; + + for(const scope of scopes) { + if(!currentScopes.includes(scope)) return false; + } + + return true; + } + } export default ApiToken diff --git a/src/core/domains/auth/factory/apiTokenFactory.ts b/src/core/domains/auth/factory/apiTokenFactory.ts index 62c67130f..5f86e45a9 100644 --- a/src/core/domains/auth/factory/apiTokenFactory.ts +++ b/src/core/domains/auth/factory/apiTokenFactory.ts @@ -22,10 +22,11 @@ class ApiTokenFactory extends Factory { * @param {IUserModel} user * @returns {IApiTokenModel} */ - createFromUser(user: IUserModel): IApiTokenModel { + createFromUser(user: IUserModel, scopes: string[] = []): IApiTokenModel { return new this.modelCtor({ userId: user.data?.id, token: tokenFactory(), + scopes: scopes, revokedAt: null, }) } diff --git a/src/core/domains/auth/interfaces/IApitokenModel.ts b/src/core/domains/auth/interfaces/IApitokenModel.ts index 35acb054f..225e87371 100644 --- a/src/core/domains/auth/interfaces/IApitokenModel.ts +++ b/src/core/domains/auth/interfaces/IApitokenModel.ts @@ -1,12 +1,15 @@ +/* eslint-disable no-unused-vars */ import { IModel } from "@src/core/interfaces/IModel"; import IModelData from "@src/core/interfaces/IModelData"; export interface IApiTokenData extends IModelData { userId: string; - token: string + token: string; + scopes: string[]; revokedAt: Date | null; } export default interface IApiTokenModel extends IModel { user(): Promise; + hasScope(scopes: string | string[]): boolean; } \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index 694d4392f..bbce1c2bd 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -56,7 +56,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - createJwtFromUser: (user: IUserModel) => Promise; + createJwtFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Creates a new ApiToken model from the User @@ -65,7 +65,7 @@ export interface IAuthService extends IService { * @returns {Promise} The new ApiToken model * @memberof IAuthService */ - createApiTokenFromUser: (user: IUserModel) => Promise; + createApiTokenFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Revokes a token. @@ -84,7 +84,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - attemptCredentials: (email: string, password: string) => Promise; + attemptCredentials: (email: string, password: string, scopes?: string[]) => Promise; /** * Generates a JWT. diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index a77d9b814..b44b4c7f2 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -58,8 +58,8 @@ export default class AuthService extends Service implements IAuthSe * @param user * @returns */ - public async createApiTokenFromUser(user: IUserModel): Promise { - const apiToken = new ApiTokenFactory().createFromUser(user) + public async createApiTokenFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = new ApiTokenFactory().createFromUser(user, scopes) await apiToken.save(); return apiToken } @@ -69,8 +69,8 @@ export default class AuthService extends Service implements IAuthSe * @param user * @returns */ - async createJwtFromUser(user: IUserModel): Promise { - const apiToken = await this.createApiTokenFromUser(user); + async createJwtFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = await this.createApiTokenFromUser(user, scopes); return this.jwt(apiToken) } @@ -139,7 +139,7 @@ export default class AuthService extends Service implements IAuthSe * @param password * @returns */ - async attemptCredentials(email: string, password: string): Promise { + async attemptCredentials(email: string, password: string, scopes: string[] = []): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; if (!user?.data?.id) { @@ -150,7 +150,7 @@ export default class AuthService extends Service implements IAuthSe throw new UnauthorizedError() } - return this.createJwtFromUser(user) + return this.createJwtFromUser(user, scopes) } /** diff --git a/src/core/domains/express/middleware/authorizeMiddleware.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts index 1283abc77..804c2fb20 100644 --- a/src/core/domains/express/middleware/authorizeMiddleware.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -1,9 +1,35 @@ +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; +/** + * Validates that the scopes in the api token match the required scopes for the request. + * If the scopes do not match, it will throw a ForbiddenResourceError. + * If no api token is found, it will throw a UnauthorizedError. + * @param scopes The scopes required for the request + * @param req The request object + * @param res The response object + */ +const validateScopes = async (scopes: string[], req: BaseRequest, res: Response) => { + if(scopes.length === 0) { + return; + } + + const apiToken = req.apiToken; + + if(!apiToken) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + + if(!apiToken.hasScope(scopes)) { + responseError(req, res, new ForbiddenResourceError('Required scopes missing from authorization'), 403); + } +} + /** * Authorize middleware * @@ -16,7 +42,7 @@ import { NextFunction, Response } from 'express'; * @param {NextFunction} next - The next function * @returns {Promise} */ -export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { +export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { try { // Authorize the request @@ -25,6 +51,9 @@ export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, // and sets the user in the App await AuthRequest.attemptAuthorizeRequest(req); + // Validate the scopes if the authorization was successful + validateScopes(scopes, req, res); + next(); } catch (error) { From 984f76f2be2921491beb65cc51f5117fe0c50a74 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 11:52:38 +0100 Subject: [PATCH 10/76] Refactored Security Rules, added hasScope Security rule --- .../domains/express/actions/resourceCreate.ts | 2 +- .../domains/express/actions/resourceDelete.ts | 2 +- .../domains/express/actions/resourceIndex.ts | 2 +- .../domains/express/actions/resourceShow.ts | 2 +- .../domains/express/actions/resourceUpdate.ts | 2 +- .../domains/express/interfaces/ISecurity.ts | 2 + .../express/middleware/securityMiddleware.ts | 37 +++++- .../{security => rules}/authorizedSecurity.ts | 0 .../{security => rules}/hasRoleSecurity.ts | 0 .../domains/express/rules/hasScopeSecurity.ts | 20 +++ .../resourceOwnerSecurity.ts | 0 src/core/domains/express/services/Security.ts | 99 ++++++--------- .../express/services/SecurityReader.ts | 36 +++++- .../domains/express/services/SecurityRules.ts | 117 ++++++++++++++++++ 14 files changed, 251 insertions(+), 70 deletions(-) rename src/core/domains/express/{security => rules}/authorizedSecurity.ts (100%) rename src/core/domains/express/{security => rules}/hasRoleSecurity.ts (100%) create mode 100644 src/core/domains/express/rules/hasScopeSecurity.ts rename src/core/domains/express/{security => rules}/resourceOwnerSecurity.ts (100%) create mode 100644 src/core/domains/express/services/SecurityRules.ts diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index bc0087b7f..62f362fca 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -21,7 +21,7 @@ import { Response } from 'express'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.CREATE]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.CREATE, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.CREATE, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 881423840..57a11fa99 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -21,7 +21,7 @@ import { Response } from 'express'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.DESTROY]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.DESTROY, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.DESTROY, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index d51f41452..a4d2f1dd8 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -30,7 +30,7 @@ const formatResults = (results: IModel[]) => results.map(result => r export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.ALL, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 0b9bff15f..0f161cabb 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -23,7 +23,7 @@ import { Response } from 'express'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.SHOW]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.SHOW, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.SHOW, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 6b1f9b976..5b2f3d40e 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -23,7 +23,7 @@ import { Response } from 'express'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.UPDATE]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.UPDATE, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.UPDATE, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index a9ba1bd6b..fba8248ce 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -21,6 +21,8 @@ export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; export type IIdentifiableSecurityCallback = { // The identifier for the security callback. id: string; + // Include another security rule in the callback. + also?: string; // The condition for when the security check should be executed. Defaults to 'always'. when: string[] | null; // The condition for when the security check should never be executed. diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index 61e3bcc7f..fbcbe8258 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -4,8 +4,9 @@ import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurity'; import responseError from '@src/core/domains/express/requests/responseError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; @@ -17,7 +18,7 @@ const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { /** * Applies the authorization security check on the request. */ -const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { +const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { const conditions = [ALWAYS] @@ -25,7 +26,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp conditions.push(route.resourceType) } - const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, conditions); + const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZED, conditions); if (authorizeSecurity) { try { @@ -33,7 +34,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp if(!authorizeSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401); - return; + return null; } } catch (err) { @@ -61,6 +62,23 @@ const applyHasRoleSecurity = (req: BaseRequest, res: Response): void | null => { } +/** + * Checks if the hasRole security has been defined and validates it. + * If the hasRole security is defined and the validation fails, it will send a 403 response with a ForbiddenResourceError. + */ +const applyHasScopeSecurity = (req: BaseRequest, res: Response): void | null => { + + // Check if the hasRole security has been defined and validate + const securityHasScope = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_SCOPE); + + if (securityHasScope && !securityHasScope.callback(req)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return null; + } + +} + + /** * This middleware will check the security definition of the route and validate it. * If the security definition is not valid, it will throw an UnauthorizedError. @@ -80,7 +98,9 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req * Authorizes the user * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth */ - await applyAuthorizeSecurity(route, req, res) + if(await applyAuthorizeSecurity(route, req, res) === null) { + return; + } /** * Check if the authorized user passes the has role security @@ -89,6 +109,13 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req return; } + /** + * Check if the authorized user passes the has scope security + */ + if(applyHasScopeSecurity(req, res) === null) { + return; + } + /** * Security is OK, continue */ diff --git a/src/core/domains/express/security/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts similarity index 100% rename from src/core/domains/express/security/authorizedSecurity.ts rename to src/core/domains/express/rules/authorizedSecurity.ts diff --git a/src/core/domains/express/security/hasRoleSecurity.ts b/src/core/domains/express/rules/hasRoleSecurity.ts similarity index 100% rename from src/core/domains/express/security/hasRoleSecurity.ts rename to src/core/domains/express/rules/hasRoleSecurity.ts diff --git a/src/core/domains/express/rules/hasScopeSecurity.ts b/src/core/domains/express/rules/hasScopeSecurity.ts new file mode 100644 index 000000000..548ec9d8e --- /dev/null +++ b/src/core/domains/express/rules/hasScopeSecurity.ts @@ -0,0 +1,20 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +/** + * Checks if the given scope(s) are present in the scopes of the current request's API token. + * If no API token is found, it will return false. + * @param req The request object + * @param scope The scope(s) to check + * @returns True if all scopes are present, false otherwise + */ +const hasScopeSecurity = (req: BaseRequest, scope: string | string[]): boolean => { + const apiToken = req.apiToken; + + if(!apiToken) { + return false; + } + + return apiToken?.hasScope(scope) +} + +export default hasScopeSecurity \ No newline at end of file diff --git a/src/core/domains/express/security/resourceOwnerSecurity.ts b/src/core/domains/express/rules/resourceOwnerSecurity.ts similarity index 100% rename from src/core/domains/express/security/resourceOwnerSecurity.ts rename to src/core/domains/express/rules/resourceOwnerSecurity.ts diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index f409522f8..409273a13 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -1,20 +1,11 @@ import Singleton from "@src/core/base/Singleton"; import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; -import authorizedSecurity from "@src/core/domains/express/security/authorizedSecurity"; -import hasRoleSecurity from "@src/core/domains/express/security/hasRoleSecurity"; -import resourceOwnerSecurity from "@src/core/domains/express/security/resourceOwnerSecurity"; -import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { IModel } from "@src/core/interfaces/IModel"; +import SecurityRules, { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; /** * A list of security identifiers. */ -export const SecurityIdentifiers = { - AUTHORIZATION: 'authorization', - RESOURCE_OWNER: 'resourceOwner', - HAS_ROLE: 'hasRole', - CUSTOM: 'custom' -} as const; + /** * The default condition for when the security check should be executed. @@ -64,9 +55,9 @@ class Security extends Singleton { * Gets and then resets the condition for when the security check should be executed to always. * @returns The when condition */ - public static getWhenAndReset(): string[] | null { - const when = this.getInstance().when; - this.getInstance().when = null; + public getWhenAndReset(): string[] | null { + const when = this.when; + this.when = null; return when; } @@ -74,26 +65,37 @@ class Security extends Singleton { * Gets and then resets the condition for when the security check should never be executed. * @returns The when condition */ - public static getNeverAndReset(): string[] | null { - const never = this.getInstance().never; - this.getInstance().never = null; + public getNeverAndReset(): string[] | null { + const never = this.never; + this.never = null; return never; } /** * Checks if the currently logged in user is the owner of the given resource. + * + * Usage with RouteResource: + * - CREATE - Adds the attribute to the resource model + * - UPDATE - Checks the authroized user is the owner of the resource + * - DELETE - Checks the authroized user is the owner of the resource + * - SHOW - Only shows the resource if the authroized user is the owner of the resource + * - INDEX - Filters the resources by the authroized user + * + * Example usage within an Action/Controller: + * + * const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, ['SomeConditionValue']); + * + * // The callback checks the attribute on the resource model matches the authorized user + * if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { + * responseError(req, res, new ForbiddenResourceError(), 403) + * return; + * } * * @param attribute - The key of the resource attribute that should contain the user id. * @returns A security callback that can be used in the security definition. */ public static resourceOwner(attribute: string = 'userId'): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.RESOURCE_OWNER, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { key: attribute }, - callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) - } + return SecurityRules[SecurityIdentifiers.RESOURCE_OWNER](attribute); } /** @@ -102,7 +104,7 @@ class Security extends Singleton { * Authorization failure does not throw any exceptions, this method allows the middleware to pass regarldess of authentication failure. * This will allow the user to have full control over the unathenticated flow. * - * Example: + * Example usage within an Action/Controller: * const authorizationSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, [ALWAYS]); * * if(authorizationSecurity && !authorizationSecurity.callback(req)) { @@ -115,15 +117,7 @@ class Security extends Singleton { * @returns A security callback that can be used in the security definition. */ public static authorized(): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.AUTHORIZATION, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { - throwExceptionOnUnauthorized: false - }, - callback: (req: BaseRequest) => authorizedSecurity(req) - } + return SecurityRules[SecurityIdentifiers.AUTHORIZED](); } /** @@ -133,15 +127,7 @@ class Security extends Singleton { * @returns A security callback that can be used in the security definition. */ public static authorizationThrowsException(): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.AUTHORIZATION, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { - throwExceptionOnUnauthorized: true - }, - callback: (req: BaseRequest) => authorizedSecurity(req) - } + return SecurityRules[SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION](); } /** @@ -150,12 +136,16 @@ class Security extends Singleton { * @returns A callback function to be used in the security definition. */ public static hasRole(roles: string | string[]): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.HAS_ROLE, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - callback: (req: BaseRequest) => hasRoleSecurity(req, roles) - } + return SecurityRules[SecurityIdentifiers.HAS_ROLE](roles); + } + + /** + * Checks if the currently logged in user has the given scope(s). + * @param scopes The scope(s) to check. + * @returns A callback function to be used in the security definition. + */ + public static hasScope(scopes: string | string[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.HAS_SCOPE](scopes); } /** @@ -166,16 +156,9 @@ class Security extends Singleton { * @param rest - The arguments for the security callback. * @returns A callback function to be used in the security definition. */ - // eslint-disable-next-line no-unused-vars + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { - return { - id: identifier, - never: Security.getNeverAndReset(), - when: Security.getWhenAndReset(), - callback: (req: BaseRequest, ...rest: any[]) => { - return callback(req, ...rest) - } - } + return SecurityRules[SecurityIdentifiers.CUSTOM](callback, ...rest); } } diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index 6411740f8..47fb5627d 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,6 +1,7 @@ import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import { ALWAYS } from "@src/core/domains/express/services/Security"; +import SecurityRules from "@src/core/domains/express/services/SecurityRules"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; class SecurityReader { @@ -39,6 +40,8 @@ class SecurityReader { * @returns The security callback if found, or undefined if not found. */ public static find(security: IIdentifiableSecurityCallback[], id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + let result: IIdentifiableSecurityCallback | undefined = undefined; + when = when ?? null; when = when && typeof when === 'string' ? [when] : when; @@ -72,11 +75,40 @@ class SecurityReader { return false; } - return security?.find(security => { - return security.id === id && + + /** + * Find by 'id' + */ + result = security?.find(security => { + const matchesIdentifier = security.id === id + + return matchesIdentifier && conditionNeverPassable(when, security.never) === false && conditionPassable(security.when); }); + + /** + * Includes security rule defined in optional 'also' property + * + * Example: hasScope rule requires authorized rule + */ + if(!result) { + + // We need to find the unrelated security rule that has the ID in 'also' + const unrelatedSecurityRule = security?.find(security => { + return security.also === id && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); + }); + + // The 'unrelatedSecurityRule' contains the 'also' property. + // We can use it to fetch the desired security rule. + if(unrelatedSecurityRule) { + result = SecurityRules[unrelatedSecurityRule.also as string]() + } + } + + return result } } diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts new file mode 100644 index 000000000..a9f3212a0 --- /dev/null +++ b/src/core/domains/express/services/SecurityRules.ts @@ -0,0 +1,117 @@ +import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity" +import authorizedSecurity from "@src/core/domains/express/rules/authorizedSecurity" +import hasRoleSecurity from "@src/core/domains/express/rules/hasRoleSecurity" +import hasScopeSecurity from "@src/core/domains/express/rules/hasScopeSecurity" +import resourceOwnerSecurity from "@src/core/domains/express/rules/resourceOwnerSecurity" +import Security from "@src/core/domains/express/services/Security" +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t" +import { IModel } from "@src/core/interfaces/IModel" + +/** + * Security rules + */ +export interface ISecurityRules { + // eslint-disable-next-line no-unused-vars + [key: string]: (...args: any[]) => IIdentifiableSecurityCallback +} + +/** + * The list of security identifiers. + */ +export const SecurityIdentifiers = { + AUTHORIZED: 'authorized', + AUTHORIZED_THROW_EXCEPTION: 'authorizedThrowException', + RESOURCE_OWNER: 'resourceOwner', + HAS_ROLE: 'hasRole', + HAS_SCOPE: 'hasScope', + CUSTOM: 'custom' +} as const; + +const SecurityRules: ISecurityRules = { + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Does not throw exceptions on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED]: () => ({ + id: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Throws an exception on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION]: () => ({ + id: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the currently logged in user is the owner of the given resource. + * @param attribute + * @returns + */ + [SecurityIdentifiers.RESOURCE_OWNER]: (attribute: string = 'userId') => ({ + id: SecurityIdentifiers.RESOURCE_OWNER, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { key: attribute }, + callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) + }), + + /** + * Checks if the currently logged in user has the given role. + * @param roles + * @returns + */ + [SecurityIdentifiers.HAS_ROLE]: (roles: string | string[]) => ({ + id: SecurityIdentifiers.HAS_ROLE, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasRoleSecurity(req, roles) + }), + + /** + * Checks if the currently logged in user has the given scope(s). + * @param scopes + * @returns + */ + [SecurityIdentifiers.HAS_SCOPE]: (scopes: string | string[]) => ({ + id: SecurityIdentifiers.HAS_SCOPE, + also: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasScopeSecurity(req, scopes) + }), + + /** + * Custom security rule + * @param callback + * @param rest + * @returns + */ + // eslint-disable-next-line no-unused-vars + [SecurityIdentifiers.CUSTOM]: (callback: SecurityCallback, ...rest: any[]) => ({ + id: SecurityIdentifiers.CUSTOM, + never: Security.getInstance().getNeverAndReset(), + when: Security.getInstance().getWhenAndReset(), + callback: (req: BaseRequest, ...rest: any[]) => { + return callback(req, ...rest) + } + }) +} as const + +export default SecurityRules \ No newline at end of file From 9d1135eff40925ca05eb0069babe04e0bc14ae65 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 11:58:31 +0100 Subject: [PATCH 11/76] Fixed incorrect import path --- src/core/domains/express/actions/resourceCreate.ts | 3 ++- src/core/domains/express/actions/resourceDelete.ts | 3 ++- src/core/domains/express/actions/resourceIndex.ts | 3 ++- src/core/domains/express/actions/resourceShow.ts | 3 ++- src/core/domains/express/actions/resourceUpdate.ts | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 62f362fca..b8143feee 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -4,8 +4,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 57a11fa99..a042ec504 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -4,8 +4,9 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedErr import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index a4d2f1dd8..8fc3186a9 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -4,8 +4,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; import IModelData from '@src/core/interfaces/IModelData'; diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 0f161cabb..a2405e661 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -5,8 +5,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 5b2f3d40e..ce9f9a0a0 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -5,8 +5,9 @@ import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSe import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; From 9341f7424695361c67937558c618666c637d93ca Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 13:00:20 +0100 Subject: [PATCH 12/76] Updated comments --- src/core/domains/express/services/Security.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 409273a13..2838f05d2 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -83,6 +83,10 @@ class Security extends Singleton { * * Example usage within an Action/Controller: * + * // Instance of a model (resource) + * const result = await repository.findById(id) + * + * // Check if resourceOwner is applicable * const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, ['SomeConditionValue']); * * // The callback checks the attribute on the resource model matches the authorized user From dcfb358f1901e13bab7ce3748d26e8fde6f43988 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 16:00:55 +0100 Subject: [PATCH 13/76] Fix arguments not working as expected in ListRoutesCommand --- src/core/domains/console/commands/ListRoutesCommand.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/domains/console/commands/ListRoutesCommand.ts b/src/core/domains/console/commands/ListRoutesCommand.ts index 826c42c25..655362c0a 100644 --- a/src/core/domains/console/commands/ListRoutesCommand.ts +++ b/src/core/domains/console/commands/ListRoutesCommand.ts @@ -9,12 +9,11 @@ export default class ListRoutesCommand extends BaseCommand { public keepProcessAlive = false; - /** * Execute the command */ async execute() { - const showDetails = (this.getArguementByKey('details')?.value ?? false) !== false; + const showDetails = this.parsedArgumenets.find(arg => ['--details', '--d', '--detailed'].includes(arg.value)); const expressService = App.container('express') this.input.clearScreen(); @@ -27,8 +26,10 @@ export default class ListRoutesCommand extends BaseCommand { this.input.writeLine(` Name: ${route.name}`); this.input.writeLine(` Method: ${route.method}`); this.input.writeLine(` Action: ${route.action.name}`); - this.input.writeLine(` Middleware: ${route.middlewares?.map(m => m.name).join(', ')}`); - this.input.writeLine(` Validators: ${route.validator?.name ?? 'None'}`); + this.input.writeLine(` Middleware: [${route.middlewares?.map(m => m.name).join(', ') ?? ''}]`); + this.input.writeLine(` Validators: [${route.validator?.name ?? ''}]`); + this.input.writeLine(` Scopes: [${route.scopes?.join(', ') ?? ''}]`); + this.input.writeLine(` Security: [${route.security?.map(s => s.id).join(', ')}]`); this.input.writeLine(); return; } From f8977af3d76a3c210e107d695b22e56b2797f0fc Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 16:09:10 +0100 Subject: [PATCH 14/76] Added Resource scopes, added scope security enabled option --- src/core/domains/express/interfaces/IRoute.ts | 4 +- .../interfaces/IRouteResourceOptions.ts | 10 ++- .../domains/express/routing/RouteResource.ts | 38 ++++++++- .../express/routing/RouteResourceScope.ts | 48 +++++++++++ .../express/services/ExpressService.ts | 80 ++++++++++++++++--- src/core/domains/express/services/Security.ts | 7 +- 6 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 src/core/domains/express/routing/RouteResourceScope.ts diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index e3e41ed99..dc1f59cd9 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -5,10 +5,12 @@ import { Middleware } from '@src/core/interfaces/Middleware.t'; export interface IRoute { name: string; - resourceType?: string; path: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; action: IRouteAction; + resourceType?: string; + scopes?: string[]; + scopesSecurityEnabled?: boolean; middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 742442534..604cdb45c 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -1,16 +1,18 @@ -import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; export interface IRouteResourceOptions extends Pick { + name: string; + resource: ModelConstructor; except?: ResourceType[]; only?: ResourceType[]; - resource: ModelConstructor; - name: string; createValidator?: ValidatorCtor; updateValidator?: ValidatorCtor; - security?: IdentifiableSecurityCallback[]; + security?: IIdentifiableSecurityCallback[]; + scopes?: string[]; + scopesSecurityEnabled?: boolean; } \ No newline at end of file diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index d7cfeae45..a067abe82 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -8,6 +8,7 @@ import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; +import RouteResourceScope from "@src/core/domains/express/routing/RouteResourceScope"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; /** @@ -40,11 +41,22 @@ export const RouteResourceTypes = { const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const name = options.name.startsWith('/') ? options.name.slice(1) : options.name + const { + scopes = [], + scopesSecurityEnabled = false + } = options; + const routes = RouteGroup([ - // Get all resources + // Get all resources Route({ name: `${name}.index`, resourceType: RouteResourceTypes.ALL, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'get', path: `/${name}`, action: resourceAction(options, resourceIndex), @@ -55,6 +67,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.show`, resourceType: RouteResourceTypes.SHOW, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'get', path: `/${name}/:id`, action: resourceAction(options, resourceShow), @@ -65,6 +83,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.update`, resourceType: RouteResourceTypes.UPDATE, + scopes: RouteResourceScope.getScopes({ + name, + types: 'write', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), @@ -76,6 +100,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.destroy`, resourceType: RouteResourceTypes.DESTROY, + scopes: RouteResourceScope.getScopes({ + name, + types: 'delete', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'delete', path: `/${name}/:id`, action: resourceAction(options, resourceDelete), @@ -86,6 +116,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.create`, resourceType: RouteResourceTypes.CREATE, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), diff --git a/src/core/domains/express/routing/RouteResourceScope.ts b/src/core/domains/express/routing/RouteResourceScope.ts new file mode 100644 index 000000000..65d16d761 --- /dev/null +++ b/src/core/domains/express/routing/RouteResourceScope.ts @@ -0,0 +1,48 @@ +export type RouteResourceScopeType = 'read' | 'write' | 'delete' | 'all'; + +export const defaultRouteResourceScopes: RouteResourceScopeType[] = ['read', 'write', 'delete', 'all']; + +export type GetScopesOptions = { + name: string, + types?: RouteResourceScopeType[] | RouteResourceScopeType, + additionalScopes?: string[] +} + +class RouteResourceScope { + + /** + * Generates a list of scopes, given a resource name and some scope types. + * @param name The name of the resource + * @param types The scope type(s) to generate scopes for. If a string, it will be an array of only that type. + * @param additionalScopes Additional scopes to append to the output + * @returns A list of scopes in the format of 'resourceName:scopeType' + * + * Example: + * + * const scopes = RouteResourceScope.getScopes('blog', ['write', 'all'], ['otherScope']) + * + * // Output + * [ + * 'blog:write', + * 'blog:all', + * 'otherScope' + * ] + */ + public static getScopes(options: GetScopesOptions): string[] { + const { + name, + types = defaultRouteResourceScopes, + additionalScopes = [] + } = options + + const typesArray = typeof types === 'string' ? [types] : types; + + return [ + ...typesArray.map(type => `${name}:${type}`), + ...additionalScopes + ]; + } + +} + +export default RouteResourceScope \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index ffe7a0e2a..59a5f89b0 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -5,6 +5,7 @@ import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; import endCurrentRequestMiddleware from '@src/core/domains/express/middleware/endCurrentRequestMiddleware'; import requestIdMiddleware from '@src/core/domains/express/middleware/requestIdMiddleware'; import { securityMiddleware } from '@src/core/domains/express/middleware/securityMiddleware'; +import SecurityRules, { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; @@ -21,7 +22,7 @@ export default class ExpressService extends Service implements I private app: express.Express private registedRoutes: IRoute[] = []; - + /** * Config defined in @src/config/http/express.ts * @param config @@ -52,9 +53,9 @@ export default class ExpressService extends Service implements I * Starts listening for connections on the port specified in the config. * If no port is specified, the service will not start listening. */ - public async listen(): Promise { - const port = this.config?.port - + public async listen(): Promise { + const port = this.config?.port + return new Promise(resolve => { this.app.listen(port, () => resolve()) }) @@ -75,11 +76,22 @@ export default class ExpressService extends Service implements I * @param route */ public bindSingleRoute(route: IRoute): void { - const middlewares = this.addValidatorMiddleware(route); + const userDefinedMiddlewares = route.middlewares ?? []; + + // Add security and validator middlewares + const middlewares: Middleware[] = [ + ...userDefinedMiddlewares, + ...this.addValidatorMiddleware(route), + ...this.addSecurityMiddleware(route), + ]; + + // Add route handlers const handlers = [...middlewares, route?.action] - console.log(`[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`) + // Log route + this.logRoute(route) + // Bind route switch (route.method) { case 'get': this.app.get(route.path, handlers); @@ -98,7 +110,7 @@ export default class ExpressService extends Service implements I break; default: throw new Error(`Unsupported method ${route.method} for path ${route.path}`); - } + } this.registedRoutes.push(route) } @@ -109,8 +121,11 @@ export default class ExpressService extends Service implements I * @returns middlewares with added validator middleware */ public addValidatorMiddleware(route: IRoute): Middleware[] { - const middlewares = [...route?.middlewares ?? []]; + const middlewares: Middleware[] = []; + /** + * Add validator middleware + */ if (route?.validator) { const validatorMiddleware = App.container('validate').middleware() const validator = new route.validator(); @@ -121,7 +136,33 @@ export default class ExpressService extends Service implements I ); } - if(route?.security) { + return middlewares; + } + + /** + * Adds security middleware to the route. If the route has scopesSecurityEnabled + * and scopes is present, it adds the HAS_SCOPE security rule to the route. + * Then it adds the security middleware to the route's middleware array. + * @param route The route to add the middleware to + * @returns The route's middleware array with the security middleware added + */ + public addSecurityMiddleware(route: IRoute): Middleware[] { + const middlewares: Middleware[] = []; + + /** + * Check if scopes is present, add related security rule + */ + if (route?.scopesSecurityEnabled && route?.scopes?.length) { + route.security = [ + ...(route.security ?? []), + SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes) + ] + } + + /** + * Add security middleware + */ + if (route?.security) { middlewares.push( securityMiddleware({ route }) ) @@ -153,4 +194,25 @@ export default class ExpressService extends Service implements I return this.registedRoutes } + /** + * Logs a route binding to the console. + * @param route - IRoute instance + */ + private logRoute(route: IRoute): void { + let str = `[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`; + + if (route.scopes?.length) { + str += ` with scopes: [${route.scopes.join(', ')}]` + + if (route?.scopesSecurityEnabled) { + str += ' (scopes security ON)' + } + else { + str += ' (scopes security OFF)' + } + } + + console.log(str) + } + } diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 2838f05d2..0d755ddba 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -2,11 +2,6 @@ import Singleton from "@src/core/base/Singleton"; import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import SecurityRules, { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; -/** - * A list of security identifiers. - */ - - /** * The default condition for when the security check should be executed. */ @@ -161,7 +156,7 @@ class Security extends Singleton { * @returns A callback function to be used in the security definition. */ - public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { + public static custom(callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { return SecurityRules[SecurityIdentifiers.CUSTOM](callback, ...rest); } From 9ae448c4d54fb9bff9badab28044398011217e2c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 16:12:40 +0100 Subject: [PATCH 15/76] Added todo comment --- src/core/domains/express/interfaces/ISecurity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index fba8248ce..2ebf4a778 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -22,6 +22,7 @@ export type IIdentifiableSecurityCallback = { // The identifier for the security callback. id: string; // Include another security rule in the callback. + // TODO: We could add another type here 'alsoArguments' if extra parameters are required also?: string; // The condition for when the security check should be executed. Defaults to 'always'. when: string[] | null; From 30a5074a7a18fdd60d2391d1478b7dad330eba84 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 16:22:53 +0100 Subject: [PATCH 16/76] Fixed incorrect security identifier --- src/core/domains/express/services/SecurityRules.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index a9f3212a0..9ea43a9ac 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -50,7 +50,7 @@ const SecurityRules: ISecurityRules = { * @returns */ [SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION]: () => ({ - id: SecurityIdentifiers.AUTHORIZED, + id: SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION, when: Security.getInstance().getWhenAndReset(), never: Security.getInstance().getNeverAndReset(), arguements: { @@ -66,6 +66,7 @@ const SecurityRules: ISecurityRules = { */ [SecurityIdentifiers.RESOURCE_OWNER]: (attribute: string = 'userId') => ({ id: SecurityIdentifiers.RESOURCE_OWNER, + also: SecurityIdentifiers.AUTHORIZED, when: Security.getInstance().getWhenAndReset(), never: Security.getInstance().getNeverAndReset(), arguements: { key: attribute }, @@ -79,6 +80,7 @@ const SecurityRules: ISecurityRules = { */ [SecurityIdentifiers.HAS_ROLE]: (roles: string | string[]) => ({ id: SecurityIdentifiers.HAS_ROLE, + also: SecurityIdentifiers.AUTHORIZED, when: Security.getInstance().getWhenAndReset(), never: Security.getInstance().getNeverAndReset(), callback: (req: BaseRequest) => hasRoleSecurity(req, roles) From d311066b37d38e55e77dc4cca7c7c04c8b6d7044 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 21:53:29 +0100 Subject: [PATCH 17/76] Fixed incorrect custom identifier, use user provided identifier --- src/core/domains/express/services/Security.ts | 4 ++-- src/core/domains/express/services/SecurityRules.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 0d755ddba..cbaf2b08c 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -156,8 +156,8 @@ class Security extends Singleton { * @returns A callback function to be used in the security definition. */ - public static custom(callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { - return SecurityRules[SecurityIdentifiers.CUSTOM](callback, ...rest); + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.CUSTOM](identifier, callback, ...rest); } } diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index 9ea43a9ac..852c00403 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -106,8 +106,8 @@ const SecurityRules: ISecurityRules = { * @returns */ // eslint-disable-next-line no-unused-vars - [SecurityIdentifiers.CUSTOM]: (callback: SecurityCallback, ...rest: any[]) => ({ - id: SecurityIdentifiers.CUSTOM, + [SecurityIdentifiers.CUSTOM]: (identifier: string, callback: SecurityCallback, ...rest: any[]) => ({ + id: identifier, never: Security.getInstance().getNeverAndReset(), when: Security.getInstance().getWhenAndReset(), callback: (req: BaseRequest, ...rest: any[]) => { From c721dbee7de351aeafa2efb837e6fa61761bae9a Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 23:17:38 +0100 Subject: [PATCH 18/76] Updated CurrentRequest to handle ipAddresses --- src/core/domains/auth/services/AuthRequest.ts | 2 +- .../domains/express/actions/resourceCreate.ts | 2 +- .../domains/express/actions/resourceIndex.ts | 2 +- .../domains/express/actions/resourceShow.ts | 2 +- .../middleware/basicLoggerMiddleware.ts | 3 +- .../middleware/endCurrentRequestMiddleware.ts | 2 + .../express/rules/authorizedSecurity.ts | 2 +- .../express/services/CurrentRequest.ts | 75 +++++++++++++++++-- .../domains/express/utils/getIpAddress.ts | 7 ++ 9 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 src/core/domains/express/utils/getIpAddress.ts diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts index 61ce2d9a6..fc56b6091 100644 --- a/src/core/domains/auth/services/AuthRequest.ts +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -28,7 +28,7 @@ class AuthRequest { req.user = user; req.apiToken = apiToken - CurrentRequest.set(req, 'userId', user?.getId()) + CurrentRequest.setByRequest(req, 'userId', user?.getId()) return req; } diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index b8143feee..c9de4d3ed 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -41,7 +41,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'userId'); + const userId = CurrentRequest.getByRequest(req, 'userId'); if(typeof propertyKey !== 'string') { throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 8fc3186a9..75b790d85 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -48,7 +48,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if (resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'userId'); + const userId = CurrentRequest.getByRequest(req, 'userId'); if (!userId) { responseError(req, res, new UnauthorizedError(), 401); diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index a2405e661..433289f24 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -40,7 +40,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if(resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.get(req, 'userId'); + const userId = CurrentRequest.getByRequest(req, 'userId'); if(!userId) { responseError(req, res, new ForbiddenResourceError(), 403); diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts index 7b6372dbe..310574806 100644 --- a/src/core/domains/express/middleware/basicLoggerMiddleware.ts +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -1,9 +1,8 @@ -import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`, 'Headers: ', req.headers, 'CurrentRequest: ', CurrentRequest.get(req)); + console.log('Request', `${req.method} ${req.url}`, 'Headers: ', req.headers); next(); }; diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts index ed4377d98..c7a4ba5ec 100644 --- a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -8,6 +8,8 @@ import { NextFunction, Response } from "express"; */ const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { res.once('finish', () => { + console.log('Request finished: ', CurrentRequest.getInstance().values) + CurrentRequest.end(req) }) diff --git a/src/core/domains/express/rules/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts index 3d6a0c9f3..e6d08a88d 100644 --- a/src/core/domains/express/rules/authorizedSecurity.ts +++ b/src/core/domains/express/rules/authorizedSecurity.ts @@ -10,7 +10,7 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * @returns True if the user is logged in, false otherwise */ const authorizedSecurity = (req: BaseRequest): boolean => { - if(CurrentRequest.get(req, 'userId')) { + if(CurrentRequest.getByRequest(req, 'userId')) { return true; } diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts index 1ef0eed3d..8faeba49f 100644 --- a/src/core/domains/express/services/CurrentRequest.ts +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -1,9 +1,33 @@ import Singleton from "@src/core/base/Singleton"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import getIpAddress from "../utils/getIpAddress"; + +const example = { + 'uuid': { + 'key': 'value', + 'key2': 'value2' + }, + '127.0.0.1': { + 'key': 'value', + } +} + class CurrentRequest extends Singleton { - protected values: Record> = {}; + /** + * Example of how the values object looks like: + * { + * 'uuid': { + * 'key': 'value', + * 'key2': 'value2' + * }, + * '127.0.0.1': { + * 'key': 'value', + * } + * } + */ + public values: Record> = {}; /** * Sets a value in the current request context @@ -13,10 +37,10 @@ class CurrentRequest extends Singleton { * @param {unknown} value - The value associated with the key * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining */ - public static set(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + public static setByRequest(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { const requestId = req.id as string; - - if(!this.getInstance().values[requestId]) { + + if (!this.getInstance().values[requestId]) { this.getInstance().values[requestId] = {} } @@ -31,16 +55,52 @@ class CurrentRequest extends Singleton { * @param {string} key - The key of the value to retrieve * @returns {T | undefined} - The value associated with the key, or undefined if not found */ - public static get(req: BaseRequest, key?: string): T | undefined { + public static getByRequest(req: BaseRequest, key?: string): T | undefined { const requestId = req.id as string; - if(!key) { + if (!key) { return this.getInstance().values[requestId] as T ?? undefined; } return this.getInstance().values[requestId]?.[key] as T ?? undefined } + /** + * Sets a value in the current request context by the request's IP address + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining + */ + public static setByIpAddress(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + const ip = getIpAddress(req); + + if (!this.getInstance().values[ip]) { + this.getInstance().values[ip] = {} + } + + this.getInstance().values[ip][key] = value; + return this; + } + + /** + * Gets a value from the current request context by the request's IP address + * + * @param {BaseRequest} req - The Express Request object + * @param {string} [key] - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public static getByIpAddress(req: BaseRequest, key?: string): T | undefined { + const ip = getIpAddress(req); + + if (!key) { + return this.getInstance().values[ip] as T ?? undefined; + } + + return this.getInstance().values[ip]?.[key] as T ?? undefined + } + /** * Ends the current request context and removes all associated values * @@ -50,6 +110,9 @@ class CurrentRequest extends Singleton { public static end(req: BaseRequest) { const requestId = req.id as string; delete this.getInstance().values[requestId]; + + // const ip = getIpAddress(req); + // delete this.getInstance().values[ip]; } } diff --git a/src/core/domains/express/utils/getIpAddress.ts b/src/core/domains/express/utils/getIpAddress.ts new file mode 100644 index 000000000..8bd700387 --- /dev/null +++ b/src/core/domains/express/utils/getIpAddress.ts @@ -0,0 +1,7 @@ +import { Request } from "express"; + +const getIpAddress = (req: Request): string => { + return req.socket.remoteAddress as string +} + +export default getIpAddress \ No newline at end of file From fac54673213a14cc9f66f93755987fe8f08cbc5c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 25 Sep 2024 23:17:51 +0100 Subject: [PATCH 19/76] Added RateLimit security --- .../exceptions/RateLimitedExceededError.ts | 8 ++ .../express/middleware/securityMiddleware.ts | 37 +++++-- .../express/rules/rateLimitedSecurity.ts | 99 +++++++++++++++++++ src/core/domains/express/services/Security.ts | 10 ++ .../domains/express/services/SecurityRules.ts | 15 +++ 5 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/core/domains/auth/exceptions/RateLimitedExceededError.ts create mode 100644 src/core/domains/express/rules/rateLimitedSecurity.ts diff --git a/src/core/domains/auth/exceptions/RateLimitedExceededError.ts b/src/core/domains/auth/exceptions/RateLimitedExceededError.ts new file mode 100644 index 000000000..d08b1c0ef --- /dev/null +++ b/src/core/domains/auth/exceptions/RateLimitedExceededError.ts @@ -0,0 +1,8 @@ +export default class RateLimitedExceededError extends Error { + + constructor(message: string = 'Too many requests. Try again later.') { + super(message); + this.name = 'RateLimitedExceededError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index fbcbe8258..18e27d509 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -10,6 +10,8 @@ import { SecurityIdentifiers } from '@src/core/domains/express/services/Security import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; +import RateLimitedExceededError from '../../auth/exceptions/RateLimitedExceededError'; + const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { req.security = route.security ?? []; } @@ -22,7 +24,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp const conditions = [ALWAYS] - if(route.resourceType) { + if (route.resourceType) { conditions.push(route.resourceType) } @@ -32,7 +34,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp try { req = await AuthRequest.attemptAuthorizeRequest(req); - if(!authorizeSecurity.callback(req)) { + if (!authorizeSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401); return null; } @@ -41,7 +43,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp if (err instanceof UnauthorizedError && authorizeSecurity.arguements?.throwExceptionOnUnauthorized) { throw err; } - + // Continue processing } } @@ -78,6 +80,20 @@ const applyHasScopeSecurity = (req: BaseRequest, res: Response): void | null => } +/** + * Checks if the rate limited security has been defined and validates it. + * If the rate limited security is defined and the validation fails, it will send a 429 response with a RateLimitedExceededError. + */ +const applyRateLimitSecurity = async (req: BaseRequest, res: Response): Promise => { + + // Find the rate limited security + const securityRateLimit = SecurityReader.findFromRequest(req, SecurityIdentifiers.RATE_LIMITED); + + if (securityRateLimit && !securityRateLimit.callback(req)) { + responseError(req, res, new RateLimitedExceededError(), 429) + return null; + } +} /** * This middleware will check the security definition of the route and validate it. @@ -91,28 +107,37 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req /** * Adds security rules to the Express Request + * This is used below to find the defined security rules */ bindSecurityToRequest(route, req); + + /** + * Check if the rate limit has been exceeded + */ + if(await applyRateLimitSecurity(req, res) === null) { + return; + } + /** * Authorizes the user * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth */ - if(await applyAuthorizeSecurity(route, req, res) === null) { + if (await applyAuthorizeSecurity(route, req, res) === null) { return; } /** * Check if the authorized user passes the has role security */ - if(applyHasRoleSecurity(req, res) === null) { + if (applyHasRoleSecurity(req, res) === null) { return; } /** * Check if the authorized user passes the has scope security */ - if(applyHasScopeSecurity(req, res) === null) { + if (applyHasScopeSecurity(req, res) === null) { return; } diff --git a/src/core/domains/express/rules/rateLimitedSecurity.ts b/src/core/domains/express/rules/rateLimitedSecurity.ts new file mode 100644 index 000000000..93ca04164 --- /dev/null +++ b/src/core/domains/express/rules/rateLimitedSecurity.ts @@ -0,0 +1,99 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { Request } from "express"; + +import RateLimitedExceededError from "../../auth/exceptions/RateLimitedExceededError"; +import CurrentRequest from "../services/CurrentRequest"; + +/** + * Handles a new request by adding the current time to the request's hit log. + * + * @param {string} id - The id of the request. + * @param {Request} req - The express request object. + */ +const handleNewRequest = (id: string, req: Request) => { + CurrentRequest.setByIpAddress(req, id, [ + ...getCurrentDates(id, req), + new Date() + ]); +} + +/** + * Reverts the last request hit by removing the latest date from the hit log. + * + * @param {string} id - The id of the request. + * @param {Request} req - The express request object. + */ +const undoNewRequest = (id: string, req: Request) => { + const dates = [...getCurrentDates(id, req)]; + dates.pop(); + CurrentRequest.setByIpAddress(req, id, dates); +} + +/** + * Gets the current hits as an array of dates for the given request and id. + * + * @param id The id of the hits to retrieve. + * @param req The request object. + * @returns The array of dates of the hits, or an empty array if not found. + */ +const getCurrentDates = (id: string, req: Request): Date[] => { + return CurrentRequest.getByIpAddress(req, id) ?? []; +} + +/** + * Finds the number of dates in the given array that are within the given start and end date range. + * @param start The start date of the range. + * @param end The end date of the range. + * @param hits The array of dates to search through. + * @returns The number of dates in the array that fall within the given range. + */ +const findDatesWithinTimeRange = (start: Date, end: Date, hits: Date[]): number => { + return hits.filter((hit) => { + return hit >= start && hit <= end; + }).length; +} + +/** + * Checks if the current request has exceeded the given rate limit per minute. + * + * @param req The request to check. + * @param limitPerMinute The maximum number of requests the user can make per minute. + * @returns true if the request has not exceeded the rate limit, false otherwise. + * @throws RateLimitedExceededError if the rate limit has been exceeded. + */ +const rateLimitedSecurity = (req: BaseRequest, limit: number, perMinuteAmount: number = 1): boolean => { + + // The identifier is the request method and url + const identifier = `rateLimited:${req.method}:${req.url}` + + // Handle a new request + // Stores dates in CurrentRequest linked by the IP address + handleNewRequest(identifier, req); + + // Get the current date + const now = new Date(); + + // Get date in the past + const dateInPast = new Date(); + dateInPast.setMinutes(dateInPast.getMinutes() - perMinuteAmount); + + // Get current requests as an array of dates + const requestAttemptsAsDateArray = getCurrentDates(identifier, req); + + // Get the number of requests within the time range + const requestAttemptCount = findDatesWithinTimeRange(dateInPast, now, requestAttemptsAsDateArray); + + // If the number of requests is greater than the limit, throw an error + if(requestAttemptCount > limit) { + // Undo the new request, we won't consider this request as part of the limit + undoNewRequest(identifier, req); + + // Throw the error + throw new RateLimitedExceededError() + } + + // Limits not exceeded + return true; +} + +export default rateLimitedSecurity \ No newline at end of file diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index cbaf2b08c..a449d32e6 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -147,6 +147,16 @@ class Security extends Singleton { return SecurityRules[SecurityIdentifiers.HAS_SCOPE](scopes); } + /** + * Creates a security callback to check if the currently IP address has not exceeded a given rate limit. + * + * @param limit - The maximum number of requests the user can make per minute.* + * @returns A callback function to be used in the security definition. + */ + public static rateLimited(limit: number, perMinuteAmount: number = 1): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.RATE_LIMITED](limit, perMinuteAmount); + } + /** * Creates a custom security callback. * diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index 852c00403..2e1945211 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -7,6 +7,8 @@ import Security from "@src/core/domains/express/services/Security" import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t" import { IModel } from "@src/core/interfaces/IModel" +import rateLimitedSecurity from "../rules/rateLimitedSecurity" + /** * Security rules */ @@ -24,6 +26,7 @@ export const SecurityIdentifiers = { RESOURCE_OWNER: 'resourceOwner', HAS_ROLE: 'hasRole', HAS_SCOPE: 'hasScope', + RATE_LIMITED: 'rateLimited', CUSTOM: 'custom' } as const; @@ -99,6 +102,18 @@ const SecurityRules: ISecurityRules = { callback: (req: BaseRequest) => hasScopeSecurity(req, scopes) }), + /** + * Rate limited security + * @param limit + * @returns + */ + [SecurityIdentifiers.RATE_LIMITED]: (limit: number, perMinuteAmount: number) => ({ + id: SecurityIdentifiers.RATE_LIMITED, + never: Security.getInstance().getNeverAndReset(), + when: Security.getInstance().getWhenAndReset(), + callback: (req: BaseRequest) => rateLimitedSecurity(req, limit, perMinuteAmount), + }), + /** * Custom security rule * @param callback From 47a455909cebfc8cb2e7526d52827092d22e6d85 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 00:10:44 +0100 Subject: [PATCH 20/76] Refactored CurrentRequest into an app container --- src/core/domains/auth/services/AuthRequest.ts | 3 +- .../domains/express/actions/resourceCreate.ts | 4 +- .../domains/express/actions/resourceIndex.ts | 4 +- .../domains/express/actions/resourceShow.ts | 4 +- .../express/interfaces/ICurrentRequest.ts | 11 ++++ .../middleware/endCurrentRequestMiddleware.ts | 6 +- .../express/middleware/securityMiddleware.ts | 3 +- .../express/providers/ExpressProvider.ts | 9 ++- .../express/rules/authorizedSecurity.ts | 4 +- .../express/rules/rateLimitedSecurity.ts | 11 ++-- .../express/services/CurrentRequest.ts | 61 +++++++++---------- .../domains/express/services/SecurityRules.ts | 3 +- src/core/interfaces/ICoreContainers.ts | 7 +++ 13 files changed, 75 insertions(+), 55 deletions(-) create mode 100644 src/core/domains/express/interfaces/ICurrentRequest.ts diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts index fc56b6091..4d378a47f 100644 --- a/src/core/domains/auth/services/AuthRequest.ts +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -1,5 +1,4 @@ import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; -import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { App } from "@src/core/services/App"; @@ -28,7 +27,7 @@ class AuthRequest { req.user = user; req.apiToken = apiToken - CurrentRequest.setByRequest(req, 'userId', user?.getId()) + App.container('currentRequest').setByRequest(req, 'userId', user?.getId()) return req; } diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index c9de4d3ed..815a8c65e 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -3,11 +3,11 @@ import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSe import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from '@src/core/services/App'; import { Response } from 'express'; @@ -41,7 +41,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.getByRequest(req, 'userId'); + const userId = App.container('currentRequest').getByRequest(req, 'userId'); if(typeof propertyKey !== 'string') { throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 75b790d85..7cb93ab97 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -3,13 +3,13 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedErr import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; import IModelData from '@src/core/interfaces/IModelData'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; /** @@ -48,7 +48,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if (resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.getByRequest(req, 'userId'); + const userId = App.container('currentRequest').getByRequest(req, 'userId'); if (!userId) { responseError(req, res, new UnauthorizedError(), 401); diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 433289f24..0d1c0f43b 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -4,13 +4,13 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedErr import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; /** @@ -40,7 +40,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if(resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = CurrentRequest.getByRequest(req, 'userId'); + const userId = App.container('currentRequest').getByRequest(req, 'userId'); if(!userId) { responseError(req, res, new ForbiddenResourceError(), 403); diff --git a/src/core/domains/express/interfaces/ICurrentRequest.ts b/src/core/domains/express/interfaces/ICurrentRequest.ts new file mode 100644 index 000000000..6899b37e1 --- /dev/null +++ b/src/core/domains/express/interfaces/ICurrentRequest.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-unused-vars */ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +export interface ICurrentRequest { + setByRequest(req: BaseRequest, key: string, value: unknown): this; + getByRequest(req: BaseRequest, key?: string): T | undefined; + setByIpAddress(req: BaseRequest, key: string, value: unknown): this; + getByIpAddress(req: BaseRequest, key?: string): T | undefined; + endRequest(req: BaseRequest): void; + getContext(): Record>; +} \ No newline at end of file diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts index c7a4ba5ec..2221ebd9d 100644 --- a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -1,5 +1,5 @@ -import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; import { NextFunction, Response } from "express"; @@ -8,9 +8,9 @@ import { NextFunction, Response } from "express"; */ const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { res.once('finish', () => { - console.log('Request finished: ', CurrentRequest.getInstance().values) + console.log('Request finished: ', App.container('currentRequest').getContext()) - CurrentRequest.end(req) + App.container('currentRequest').endRequest(req) }) next() diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index 18e27d509..d6f0f7907 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -1,4 +1,5 @@ import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import RateLimitedExceededError from '@src/core/domains/auth/exceptions/RateLimitedExceededError'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; @@ -10,8 +11,6 @@ import { SecurityIdentifiers } from '@src/core/domains/express/services/Security import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; -import RateLimitedExceededError from '../../auth/exceptions/RateLimitedExceededError'; - const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { req.security = route.security ?? []; } diff --git a/src/core/domains/express/providers/ExpressProvider.ts b/src/core/domains/express/providers/ExpressProvider.ts index f230e77a3..fc2220cd3 100644 --- a/src/core/domains/express/providers/ExpressProvider.ts +++ b/src/core/domains/express/providers/ExpressProvider.ts @@ -1,8 +1,9 @@ import httpConfig from '@src/config/http'; import BaseProvider from "@src/core/base/Provider"; import IExpressConfig from "@src/core/domains/express/interfaces/IExpressConfig"; -import { App } from "@src/core/services/App"; +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import ExpressService from '@src/core/domains/express/services/ExpressService'; +import { App } from "@src/core/services/App"; export default class ExpressProvider extends BaseProvider { @@ -27,6 +28,12 @@ export default class ExpressProvider extends BaseProvider { // Register the Express service in the container // This will be available in any provider or service as App.container('express') App.setContainer('express', new ExpressService(this.config)); + + // Register the CurrentRequest service in the container + // This will be available in any provider or service as App.container('currentRequest') + // The CurrentRequest class can be used to store data over a request's life cycle + // Additionally, data can be stored which can be linked to the requests IP Address + App.setContainer('currentRequest', new CurrentRequest()); } /** diff --git a/src/core/domains/express/rules/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts index e6d08a88d..3a002ed3d 100644 --- a/src/core/domains/express/rules/authorizedSecurity.ts +++ b/src/core/domains/express/rules/authorizedSecurity.ts @@ -1,6 +1,6 @@ -import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; /** @@ -10,7 +10,7 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * @returns True if the user is logged in, false otherwise */ const authorizedSecurity = (req: BaseRequest): boolean => { - if(CurrentRequest.getByRequest(req, 'userId')) { + if(App.container('currentRequest').getByRequest(req, 'userId')) { return true; } diff --git a/src/core/domains/express/rules/rateLimitedSecurity.ts b/src/core/domains/express/rules/rateLimitedSecurity.ts index 93ca04164..232e2ff89 100644 --- a/src/core/domains/express/rules/rateLimitedSecurity.ts +++ b/src/core/domains/express/rules/rateLimitedSecurity.ts @@ -1,9 +1,8 @@ +import RateLimitedExceededError from "@src/core/domains/auth/exceptions/RateLimitedExceededError"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; import { Request } from "express"; -import RateLimitedExceededError from "../../auth/exceptions/RateLimitedExceededError"; -import CurrentRequest from "../services/CurrentRequest"; - /** * Handles a new request by adding the current time to the request's hit log. * @@ -11,7 +10,7 @@ import CurrentRequest from "../services/CurrentRequest"; * @param {Request} req - The express request object. */ const handleNewRequest = (id: string, req: Request) => { - CurrentRequest.setByIpAddress(req, id, [ + App.container('currentRequest').setByIpAddress(req, id, [ ...getCurrentDates(id, req), new Date() ]); @@ -26,7 +25,7 @@ const handleNewRequest = (id: string, req: Request) => { const undoNewRequest = (id: string, req: Request) => { const dates = [...getCurrentDates(id, req)]; dates.pop(); - CurrentRequest.setByIpAddress(req, id, dates); + App.container('currentRequest').setByIpAddress(req, id, dates); } /** @@ -37,7 +36,7 @@ const undoNewRequest = (id: string, req: Request) => { * @returns The array of dates of the hits, or an empty array if not found. */ const getCurrentDates = (id: string, req: Request): Date[] => { - return CurrentRequest.getByIpAddress(req, id) ?? []; + return App.container('currentRequest').getByIpAddress(req, id) ?? []; } /** diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts index 8faeba49f..16c31ee6f 100644 --- a/src/core/domains/express/services/CurrentRequest.ts +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -1,18 +1,11 @@ import Singleton from "@src/core/base/Singleton"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import getIpAddress from "@src/core/domains/express/utils/getIpAddress"; -import getIpAddress from "../utils/getIpAddress"; - -const example = { - 'uuid': { - 'key': 'value', - 'key2': 'value2' - }, - '127.0.0.1': { - 'key': 'value', - } -} - +/** + * Allows you to store information during the duration of a request. Information is linked to the Request's UUID and is cleaned up after the request is finished. + * You can also store information linked to an IP Address. + */ class CurrentRequest extends Singleton { /** @@ -27,7 +20,7 @@ class CurrentRequest extends Singleton { * } * } */ - public values: Record> = {}; + protected context: Record> = {}; /** * Sets a value in the current request context @@ -37,14 +30,14 @@ class CurrentRequest extends Singleton { * @param {unknown} value - The value associated with the key * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining */ - public static setByRequest(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + public setByRequest(req: BaseRequest, key: string, value: unknown): this { const requestId = req.id as string; - if (!this.getInstance().values[requestId]) { - this.getInstance().values[requestId] = {} + if (!this.context[requestId]) { + this.context[requestId] = {} } - this.getInstance().values[requestId][key] = value; + this.context[requestId][key] = value; return this; } @@ -55,14 +48,14 @@ class CurrentRequest extends Singleton { * @param {string} key - The key of the value to retrieve * @returns {T | undefined} - The value associated with the key, or undefined if not found */ - public static getByRequest(req: BaseRequest, key?: string): T | undefined { + public getByRequest(req: BaseRequest, key?: string): T | undefined { const requestId = req.id as string; if (!key) { - return this.getInstance().values[requestId] as T ?? undefined; + return this.context[requestId] as T ?? undefined; } - return this.getInstance().values[requestId]?.[key] as T ?? undefined + return this.context[requestId]?.[key] as T ?? undefined } /** @@ -73,14 +66,14 @@ class CurrentRequest extends Singleton { * @param {unknown} value - The value associated with the key * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining */ - public static setByIpAddress(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + public setByIpAddress(req: BaseRequest, key: string, value: unknown): this { const ip = getIpAddress(req); - if (!this.getInstance().values[ip]) { - this.getInstance().values[ip] = {} + if (!this.context[ip]) { + this.context[ip] = {} } - this.getInstance().values[ip][key] = value; + this.context[ip][key] = value; return this; } @@ -91,14 +84,14 @@ class CurrentRequest extends Singleton { * @param {string} [key] - The key of the value to retrieve * @returns {T | undefined} - The value associated with the key, or undefined if not found */ - public static getByIpAddress(req: BaseRequest, key?: string): T | undefined { + public getByIpAddress(req: BaseRequest, key?: string): T | undefined { const ip = getIpAddress(req); if (!key) { - return this.getInstance().values[ip] as T ?? undefined; + return this.context[ip] as T ?? undefined; } - return this.getInstance().values[ip]?.[key] as T ?? undefined + return this.context[ip]?.[key] as T ?? undefined } /** @@ -107,12 +100,18 @@ class CurrentRequest extends Singleton { * @param {BaseRequest} req - The Express Request object * @returns {void} */ - public static end(req: BaseRequest) { + public endRequest(req: BaseRequest) { const requestId = req.id as string; - delete this.getInstance().values[requestId]; + delete this.context[requestId]; + } - // const ip = getIpAddress(req); - // delete this.getInstance().values[ip]; + /** + * Returns the current request context data + * + * @returns {Record} - The current request context data + */ + public getContext() { + return this.context } } diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index 2e1945211..fc76b0460 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -2,13 +2,12 @@ import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domai import authorizedSecurity from "@src/core/domains/express/rules/authorizedSecurity" import hasRoleSecurity from "@src/core/domains/express/rules/hasRoleSecurity" import hasScopeSecurity from "@src/core/domains/express/rules/hasScopeSecurity" +import rateLimitedSecurity from "@src/core/domains/express/rules/rateLimitedSecurity" import resourceOwnerSecurity from "@src/core/domains/express/rules/resourceOwnerSecurity" import Security from "@src/core/domains/express/services/Security" import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t" import { IModel } from "@src/core/interfaces/IModel" -import rateLimitedSecurity from "../rules/rateLimitedSecurity" - /** * Security rules */ diff --git a/src/core/interfaces/ICoreContainers.ts b/src/core/interfaces/ICoreContainers.ts index b8dabc32c..867a15319 100644 --- a/src/core/interfaces/ICoreContainers.ts +++ b/src/core/interfaces/ICoreContainers.ts @@ -2,6 +2,7 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; import ICommandService from '@src/core/domains/console/interfaces/ICommandService'; import { IDatabaseService } from '@src/core/domains/database/interfaces/IDatabaseService'; import { IEventService } from '@src/core/domains/events/interfaces/IEventService'; +import { ICurrentRequest } from '@src/core/domains/express/interfaces/ICurrentRequest'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; import IValidatorService from '@src/core/domains/validator/interfaces/IValidatorService'; import readline from 'node:readline'; @@ -33,6 +34,12 @@ export interface ICoreContainers { */ express: IExpressService; + /** + * Current Request Service + * Provided by '@src/core/domains/express/providers/ExpressProvider' + */ + currentRequest: ICurrentRequest; + /** * Console service * Provided by '@src/core/domains/console/providers/ConsoleProvider' From 5f36ea1ea09844ed9e9599d2b6e7a635da8ba5a3 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 17:06:17 +0100 Subject: [PATCH 21/76] CurrentRequest to become RequestContext, tested rate limiting security and added more comments --- src/config/http.ts | 10 + src/core/domains/auth/services/AuthRequest.ts | 2 +- .../domains/express/actions/resourceCreate.ts | 2 +- .../domains/express/actions/resourceIndex.ts | 2 +- .../domains/express/actions/resourceShow.ts | 2 +- .../express/interfaces/ICurrentRequest.ts | 19 +- .../ICurrentRequestCleanUpConfig.ts | 3 + .../express/interfaces/IExpressConfig.ts | 1 + .../middleware/basicLoggerMiddleware.ts | 2 +- ...ware.ts => endRequestContextMiddleware.ts} | 9 +- .../requestContextLoggerMiddleware.ts | 24 +++ .../express/middleware/requestIdMiddleware.ts | 1 - .../express/middleware/securityMiddleware.ts | 15 ++ .../express/providers/ExpressProvider.ts | 22 ++- .../express/rules/authorizedSecurity.ts | 2 +- .../express/rules/rateLimitedSecurity.ts | 95 ++++++---- .../express/services/CurrentRequest.ts | 119 ------------ .../express/services/ExpressService.ts | 15 +- .../express/services/RequestContext.ts | 173 ++++++++++++++++++ .../express/services/RequestContextCleaner.ts | 94 ++++++++++ src/core/interfaces/ICoreContainers.ts | 6 +- 21 files changed, 430 insertions(+), 188 deletions(-) create mode 100644 src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts rename src/core/domains/express/middleware/{endCurrentRequestMiddleware.ts => endRequestContextMiddleware.ts} (58%) create mode 100644 src/core/domains/express/middleware/requestContextLoggerMiddleware.ts delete mode 100644 src/core/domains/express/services/CurrentRequest.ts create mode 100644 src/core/domains/express/services/RequestContext.ts create mode 100644 src/core/domains/express/services/RequestContextCleaner.ts diff --git a/src/config/http.ts b/src/config/http.ts index 08276574d..49ba1656c 100644 --- a/src/config/http.ts +++ b/src/config/http.ts @@ -4,10 +4,20 @@ import bodyParser from 'body-parser'; import express from 'express'; const config: IExpressConfig = { + + /** + * Enable Express + */ enabled: parseBooleanFromString(process.env.ENABLE_EXPRESS, 'true'), + /** + * HTTP port + */ port: parseInt(process.env.APP_PORT ?? '5000'), + /** + * Global middlewares + */ globalMiddlewares: [ express.json(), bodyParser.urlencoded({ extended: true }), diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts index 4d378a47f..0c7959cc0 100644 --- a/src/core/domains/auth/services/AuthRequest.ts +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -27,7 +27,7 @@ class AuthRequest { req.user = user; req.apiToken = apiToken - App.container('currentRequest').setByRequest(req, 'userId', user?.getId()) + App.container('requestContext').setByRequest(req, 'userId', user?.getId()) return req; } diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 815a8c65e..1da654d30 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -41,7 +41,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('currentRequest').getByRequest(req, 'userId'); + const userId = App.container('requestContext').getByRequest(req, 'userId'); if(typeof propertyKey !== 'string') { throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 7cb93ab97..11863f864 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -48,7 +48,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if (resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('currentRequest').getByRequest(req, 'userId'); + const userId = App.container('requestContext').getByRequest(req, 'userId'); if (!userId) { responseError(req, res, new UnauthorizedError(), 401); diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 0d1c0f43b..18c5aba89 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -40,7 +40,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp if(resourceOwnerSecurity && authorizationSecurity) { const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('currentRequest').getByRequest(req, 'userId'); + const userId = App.container('requestContext').getByRequest(req, 'userId'); if(!userId) { responseError(req, res, new ForbiddenResourceError(), 403); diff --git a/src/core/domains/express/interfaces/ICurrentRequest.ts b/src/core/domains/express/interfaces/ICurrentRequest.ts index 6899b37e1..4c728e27a 100644 --- a/src/core/domains/express/interfaces/ICurrentRequest.ts +++ b/src/core/domains/express/interfaces/ICurrentRequest.ts @@ -1,11 +1,20 @@ /* eslint-disable no-unused-vars */ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -export interface ICurrentRequest { - setByRequest(req: BaseRequest, key: string, value: unknown): this; +export interface IRequestContextData extends Map> {} + +export type IPDatesArrayTTL = { value: T; ttlSeconds: number | null }; + +export type IPContextData = Map>>; + +export interface IRequestContext { + setByRequest(req: BaseRequest, key: string, value: T): this; getByRequest(req: BaseRequest, key?: string): T | undefined; - setByIpAddress(req: BaseRequest, key: string, value: unknown): this; + setByIpAddress(req: BaseRequest, key: string, value: T, ttlSeconds?: number): this; getByIpAddress(req: BaseRequest, key?: string): T | undefined; - endRequest(req: BaseRequest): void; - getContext(): Record>; + endRequestContext(req: BaseRequest): void; + getRequestContext(): IRequestContextData; + setRequestContext(context: IRequestContextData): this; + getIpContext(): IPContextData; + setIpContext(context: IPContextData): this; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts b/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts new file mode 100644 index 000000000..e907c9437 --- /dev/null +++ b/src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig.ts @@ -0,0 +1,3 @@ +export interface ICurrentRequestCleanUpConfig { + delayInSeconds: number +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IExpressConfig.ts b/src/core/domains/express/interfaces/IExpressConfig.ts index 267815caf..62bf85445 100644 --- a/src/core/domains/express/interfaces/IExpressConfig.ts +++ b/src/core/domains/express/interfaces/IExpressConfig.ts @@ -4,4 +4,5 @@ export default interface IExpressConfig { enabled: boolean; port: number; globalMiddlewares?: express.RequestHandler[]; + currentRequestCleanupDelay?: number; } \ No newline at end of file diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts index 310574806..38d21c00f 100644 --- a/src/core/domains/express/middleware/basicLoggerMiddleware.ts +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -3,6 +3,6 @@ import { NextFunction, Response } from 'express'; export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`, 'Headers: ', req.headers); + console.log('New request: ', `${req.method} ${req.url}`, 'Headers: ', req.headers); next(); }; diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endRequestContextMiddleware.ts similarity index 58% rename from src/core/domains/express/middleware/endCurrentRequestMiddleware.ts rename to src/core/domains/express/middleware/endRequestContextMiddleware.ts index 2221ebd9d..9e807628c 100644 --- a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts +++ b/src/core/domains/express/middleware/endRequestContextMiddleware.ts @@ -3,17 +3,16 @@ import { App } from "@src/core/services/App"; import { NextFunction, Response } from "express"; + /** * Middleware that ends the current request context and removes all associated values. */ -const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { +const endRequestContextMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { res.once('finish', () => { - console.log('Request finished: ', App.container('currentRequest').getContext()) - - App.container('currentRequest').endRequest(req) + App.container('requestContext').endRequestContext(req) }) next() } -export default endCurrentRequestMiddleware \ No newline at end of file +export default endRequestContextMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts new file mode 100644 index 000000000..dc4342c87 --- /dev/null +++ b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts @@ -0,0 +1,24 @@ +import { EnvironmentDevelopment } from "@src/core/consts/Environment"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; +import { NextFunction, Response } from "express"; + +/** + * Middleware to log the request context + */ +const requestContextLoggerMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { + + if(App.env() !== EnvironmentDevelopment) { + next() + return; + } + + res.once('finish', () => { + console.log('requestContext: ', App.container('requestContext').getRequestContext()) + console.log('ipContext: ', App.container('requestContext').getIpContext()) + }) + + next() +} + +export default requestContextLoggerMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/requestIdMiddleware.ts b/src/core/domains/express/middleware/requestIdMiddleware.ts index df67c4fd2..f73fe89ad 100644 --- a/src/core/domains/express/middleware/requestIdMiddleware.ts +++ b/src/core/domains/express/middleware/requestIdMiddleware.ts @@ -33,7 +33,6 @@ const requestIdMiddleware = ({ generator, setHeader, headerName }: Props = defau } req.id = id - next() } diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index d6f0f7907..f10fc77bc 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -27,18 +27,23 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp conditions.push(route.resourceType) } + // Check if the authorize security has been defined for this route const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZED, conditions); if (authorizeSecurity) { try { + // Authorize the request req = await AuthRequest.attemptAuthorizeRequest(req); + // Validate the authentication if (!authorizeSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401); return null; } } catch (err) { + + // Conditionally throw error if (err instanceof UnauthorizedError && authorizeSecurity.arguements?.throwExceptionOnUnauthorized) { throw err; } @@ -151,6 +156,16 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req return; } + if (error instanceof RateLimitedExceededError) { + responseError(req, res, error, 429) + return; + } + + if (error instanceof ForbiddenResourceError) { + responseError(req, res, error, 403) + return; + } + if (error instanceof Error) { responseError(req, res, error) return; diff --git a/src/core/domains/express/providers/ExpressProvider.ts b/src/core/domains/express/providers/ExpressProvider.ts index fc2220cd3..9323a342b 100644 --- a/src/core/domains/express/providers/ExpressProvider.ts +++ b/src/core/domains/express/providers/ExpressProvider.ts @@ -1,10 +1,12 @@ import httpConfig from '@src/config/http'; import BaseProvider from "@src/core/base/Provider"; import IExpressConfig from "@src/core/domains/express/interfaces/IExpressConfig"; -import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; import ExpressService from '@src/core/domains/express/services/ExpressService'; +import RequestContext from '@src/core/domains/express/services/RequestContext'; +import RequestContextCleaner from '@src/core/domains/express/services/RequestContextCleaner'; import { App } from "@src/core/services/App"; + export default class ExpressProvider extends BaseProvider { /** @@ -29,11 +31,11 @@ export default class ExpressProvider extends BaseProvider { // This will be available in any provider or service as App.container('express') App.setContainer('express', new ExpressService(this.config)); - // Register the CurrentRequest service in the container - // This will be available in any provider or service as App.container('currentRequest') - // The CurrentRequest class can be used to store data over a request's life cycle - // Additionally, data can be stored which can be linked to the requests IP Address - App.setContainer('currentRequest', new CurrentRequest()); + // Register the RequestContext service in the container + // This will be available in any provider or service as App.container('requestContext') + // The RequestContext class can be used to store data over a request's life cycle + // Additionally, data can be stored which can be linked to the requests IP Address with a TTL + App.setContainer('requestContext', new RequestContext()); } /** @@ -65,6 +67,14 @@ export default class ExpressProvider extends BaseProvider { */ await express.listen(); + /** + * Start the RequestContextCleaner + */ + RequestContextCleaner.boot({ + delayInSeconds: this.config.currentRequestCleanupDelay ?? 30 + }) + + // Log that Express is successfully listening this.log('Express successfully listening on port ' + express.getConfig()?.port); } diff --git a/src/core/domains/express/rules/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts index 3a002ed3d..3272cdb93 100644 --- a/src/core/domains/express/rules/authorizedSecurity.ts +++ b/src/core/domains/express/rules/authorizedSecurity.ts @@ -10,7 +10,7 @@ import { App } from "@src/core/services/App"; * @returns True if the user is logged in, false otherwise */ const authorizedSecurity = (req: BaseRequest): boolean => { - if(App.container('currentRequest').getByRequest(req, 'userId')) { + if(App.container('requestContext').getByRequest(req, 'userId')) { return true; } diff --git a/src/core/domains/express/rules/rateLimitedSecurity.ts b/src/core/domains/express/rules/rateLimitedSecurity.ts index 232e2ff89..bfd711bd8 100644 --- a/src/core/domains/express/rules/rateLimitedSecurity.ts +++ b/src/core/domains/express/rules/rateLimitedSecurity.ts @@ -1,54 +1,68 @@ import RateLimitedExceededError from "@src/core/domains/auth/exceptions/RateLimitedExceededError"; +import { IPDatesArrayTTL } from "@src/core/domains/express/interfaces/ICurrentRequest"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { App } from "@src/core/services/App"; import { Request } from "express"; /** - * Handles a new request by adding the current time to the request's hit log. - * - * @param {string} id - The id of the request. - * @param {Request} req - The express request object. + * Adds a new date to the rate limited context. + * + * @param ipContextIdentifier - The rate limited context id. + * @param req - The express request object. + * @param ttlSeconds - The ttl in seconds of the context. */ -const handleNewRequest = (id: string, req: Request) => { - App.container('currentRequest').setByIpAddress(req, id, [ - ...getCurrentDates(id, req), +const addDate = (ipContextIdentifier: string, req: Request, ttlSeconds: number) => { + const context = getContext(ipContextIdentifier, req); + const dates = context.value + + App.container('requestContext').setByIpAddress(req, ipContextIdentifier, [ + ...dates, new Date() - ]); + ], ttlSeconds) } /** - * Reverts the last request hit by removing the latest date from the hit log. - * - * @param {string} id - The id of the request. - * @param {Request} req - The express request object. + * Removes the last date from the rate limited context. + * + * @param ipContextIdentifier - The rate limited context id. + * @param req - The express request object. */ -const undoNewRequest = (id: string, req: Request) => { - const dates = [...getCurrentDates(id, req)]; - dates.pop(); - App.container('currentRequest').setByIpAddress(req, id, dates); +const removeLastDate = (ipContextIdentifier: string, req: Request) => { + const context = getContext(ipContextIdentifier, req); + const dates = context.value; + const ttlSeconds = context.ttlSeconds ?? undefined; + const newDates = [...dates]; + newDates.pop(); + + App.container('requestContext').setByIpAddress(req, ipContextIdentifier, newDates, ttlSeconds) } + /** - * Gets the current hits as an array of dates for the given request and id. - * - * @param id The id of the hits to retrieve. - * @param req The request object. - * @returns The array of dates of the hits, or an empty array if not found. + * Gets the current rate limited context for the given id and request. + * + * Returns an object with a "value" property containing an array of Date objects and a "ttlSeconds" property containing the TTL in seconds. + * Example: { value: [Date, Date], ttlSeconds: 60 } + * + * @param id - The rate limited context id. + * @param req - The express request object. + * @returns The current rate limited context value with the given id, or an empty array if none exists. */ -const getCurrentDates = (id: string, req: Request): Date[] => { - return App.container('currentRequest').getByIpAddress(req, id) ?? []; +const getContext = (id: string, req: Request): IPDatesArrayTTL => { + return App.container('requestContext').getByIpAddress>(req, id) || { value: [], ttlSeconds: null }; } /** * Finds the number of dates in the given array that are within the given start and end date range. + * * @param start The start date of the range. * @param end The end date of the range. - * @param hits The array of dates to search through. + * @param dates The array of dates to search through. * @returns The number of dates in the array that fall within the given range. */ -const findDatesWithinTimeRange = (start: Date, end: Date, hits: Date[]): number => { - return hits.filter((hit) => { - return hit >= start && hit <= end; +const findDatesWithinTimeRange = (start: Date, end: Date, dates: Date[]): number => { + return dates.filter((date) => { + return date >= start && date <= end; }).length; } @@ -62,12 +76,14 @@ const findDatesWithinTimeRange = (start: Date, end: Date, hits: Date[]): number */ const rateLimitedSecurity = (req: BaseRequest, limit: number, perMinuteAmount: number = 1): boolean => { - // The identifier is the request method and url - const identifier = `rateLimited:${req.method}:${req.url}` + // Get pathname from request + const url = new URL(req.url, `http${req.secure ? 's' : ''}://${req.headers.host}`); - // Handle a new request - // Stores dates in CurrentRequest linked by the IP address - handleNewRequest(identifier, req); + // The id for the rate limited context + const ipContextIdentifier = `rateLimited:${req.method}:${url.pathname}` + + // Update the context with a new date + addDate(ipContextIdentifier, req, perMinuteAmount * 60); // Get the current date const now = new Date(); @@ -76,16 +92,17 @@ const rateLimitedSecurity = (req: BaseRequest, limit: number, perMinuteAmount: n const dateInPast = new Date(); dateInPast.setMinutes(dateInPast.getMinutes() - perMinuteAmount); - // Get current requests as an array of dates - const requestAttemptsAsDateArray = getCurrentDates(identifier, req); + // Get an array of dates that represents that hit log + const datesArray = getContext(ipContextIdentifier, req).value; - // Get the number of requests within the time range - const requestAttemptCount = findDatesWithinTimeRange(dateInPast, now, requestAttemptsAsDateArray); + // Filter down the array of dates that match our specified time from past to now + const attemptCount = findDatesWithinTimeRange(dateInPast, now, datesArray); // If the number of requests is greater than the limit, throw an error - if(requestAttemptCount > limit) { - // Undo the new request, we won't consider this request as part of the limit - undoNewRequest(identifier, req); + if(attemptCount > limit) { + + // Undo the last added date, we won't consider this failed request as part of the limit + removeLastDate(ipContextIdentifier, req); // Throw the error throw new RateLimitedExceededError() diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts deleted file mode 100644 index 16c31ee6f..000000000 --- a/src/core/domains/express/services/CurrentRequest.ts +++ /dev/null @@ -1,119 +0,0 @@ -import Singleton from "@src/core/base/Singleton"; -import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import getIpAddress from "@src/core/domains/express/utils/getIpAddress"; - -/** - * Allows you to store information during the duration of a request. Information is linked to the Request's UUID and is cleaned up after the request is finished. - * You can also store information linked to an IP Address. - */ -class CurrentRequest extends Singleton { - - /** - * Example of how the values object looks like: - * { - * 'uuid': { - * 'key': 'value', - * 'key2': 'value2' - * }, - * '127.0.0.1': { - * 'key': 'value', - * } - * } - */ - protected context: Record> = {}; - - /** - * Sets a value in the current request context - * - * @param {BaseRequest} req - The Express Request object - * @param {string} key - The key of the value to set - * @param {unknown} value - The value associated with the key - * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining - */ - public setByRequest(req: BaseRequest, key: string, value: unknown): this { - const requestId = req.id as string; - - if (!this.context[requestId]) { - this.context[requestId] = {} - } - - this.context[requestId][key] = value; - return this; - } - - /** - * Gets a value from the current request context - * - * @param {BaseRequest} req - The Express Request object - * @param {string} key - The key of the value to retrieve - * @returns {T | undefined} - The value associated with the key, or undefined if not found - */ - public getByRequest(req: BaseRequest, key?: string): T | undefined { - const requestId = req.id as string; - - if (!key) { - return this.context[requestId] as T ?? undefined; - } - - return this.context[requestId]?.[key] as T ?? undefined - } - - /** - * Sets a value in the current request context by the request's IP address - * - * @param {BaseRequest} req - The Express Request object - * @param {string} key - The key of the value to set - * @param {unknown} value - The value associated with the key - * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining - */ - public setByIpAddress(req: BaseRequest, key: string, value: unknown): this { - const ip = getIpAddress(req); - - if (!this.context[ip]) { - this.context[ip] = {} - } - - this.context[ip][key] = value; - return this; - } - - /** - * Gets a value from the current request context by the request's IP address - * - * @param {BaseRequest} req - The Express Request object - * @param {string} [key] - The key of the value to retrieve - * @returns {T | undefined} - The value associated with the key, or undefined if not found - */ - public getByIpAddress(req: BaseRequest, key?: string): T | undefined { - const ip = getIpAddress(req); - - if (!key) { - return this.context[ip] as T ?? undefined; - } - - return this.context[ip]?.[key] as T ?? undefined - } - - /** - * Ends the current request context and removes all associated values - * - * @param {BaseRequest} req - The Express Request object - * @returns {void} - */ - public endRequest(req: BaseRequest) { - const requestId = req.id as string; - delete this.context[requestId]; - } - - /** - * Returns the current request context data - * - * @returns {Record} - The current request context data - */ - public getContext() { - return this.context - } - -} - -export default CurrentRequest \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 59a5f89b0..6cf798f04 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -2,7 +2,7 @@ import Service from '@src/core/base/Service'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; -import endCurrentRequestMiddleware from '@src/core/domains/express/middleware/endCurrentRequestMiddleware'; +import endRequestContextMiddleware from '@src/core/domains/express/middleware/endRequestContextMiddleware'; import requestIdMiddleware from '@src/core/domains/express/middleware/requestIdMiddleware'; import { securityMiddleware } from '@src/core/domains/express/middleware/securityMiddleware'; import SecurityRules, { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; @@ -41,9 +41,16 @@ export default class ExpressService extends Service implements I throw new Error('Config not provided'); } + // Adds an identifier to the request object + // This id is used in the requestContext service to store information over a request life cycle this.app.use(requestIdMiddleware()) - this.app.use(endCurrentRequestMiddleware()) + // End the request context + // This will be called when the request is finished + // Deletes the request context and associated values + this.app.use(endRequestContextMiddleware()) + + // Apply global middlewares for (const middleware of this.config?.globalMiddlewares ?? []) { this.app.use(middleware); } @@ -160,8 +167,8 @@ export default class ExpressService extends Service implements I } /** - * Add security middleware - */ + * Add security middleware + */ if (route?.security) { middlewares.push( securityMiddleware({ route }) diff --git a/src/core/domains/express/services/RequestContext.ts b/src/core/domains/express/services/RequestContext.ts new file mode 100644 index 000000000..7445d52ab --- /dev/null +++ b/src/core/domains/express/services/RequestContext.ts @@ -0,0 +1,173 @@ +import Singleton from "@src/core/base/Singleton"; +import { IPContextData, IRequestContext, IRequestContextData } from "@src/core/domains/express/interfaces/ICurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import getIpAddress from "@src/core/domains/express/utils/getIpAddress"; + + +/** + * Current request service + * + * - Stores the current request context + * - Store the current IP context + */ +class RequestContext extends Singleton implements IRequestContext { + + /** + * Request context + * + * Example of how the values object looks like: + * { + * '': { + * 'key': unknown, + * 'key2': unknown + * } + * } + */ + protected requestContext: IRequestContextData = new Map(); + + /** + * IP context + * + * Example of how the values object looks like: + * { + * '127.0.0.1': { + * 'key': unknown, + * 'key2': unknown + * } + * } + */ + protected ipContext: IPContextData = new Map(); + + /** + * Sets a value in the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof RequestContext} - The CurrentRequest class itself to enable chaining + */ + public setByRequest(req: BaseRequest, key: string, value: T): this { + const requestId = req.id as string; + + if(!this.requestContext.has(requestId)) { + this.requestContext.set(requestId, new Map()); + } + + this.requestContext.get(requestId)!.set(key, value); + + return this; + } + + /** + * Gets a value from the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public getByRequest(req: BaseRequest, key?: string): T | undefined { + const requestId = req.id as string; + + if (!key) { + return this.requestContext.get(requestId) as T ?? undefined; + } + + return this.requestContext.get(requestId)?.get(key) as T ?? undefined + } + + /** + * Sets a value in the current request context by the request's IP address. + * + * If the ttlSeconds is not provided, the value will be stored indefinitely (only in memory). + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof RequestContext} - The CurrentRequest class itself to enable chaining + */ + public setByIpAddress(req: BaseRequest, key: string, value: T, ttlSeconds?: number): this { + const ip = getIpAddress(req); + + if(!this.ipContext.has(ip)) { + this.ipContext.set(ip, new Map()); + } + + this.ipContext.get(ip)!.set(key, { + value, + ttlSeconds: ttlSeconds ?? null + }) + + return this; + } + + /** + * Gets a value from the current request context by the request's IP address + * + * @param {BaseRequest} req - The Express Request object + * @param {string} [key] - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public getByIpAddress(req: BaseRequest, key?: string): T | undefined { + const ip = getIpAddress(req); + + if (!key) { + return this.ipContext.get(ip) as T ?? undefined; + } + + return this.ipContext.get(ip)?.get(key) as T ?? undefined + } + + /** + * Ends the current request context and removes all associated values + * + * @param {BaseRequest} req - The Express Request object + * @returns {void} + */ + public endRequestContext(req: BaseRequest) { + const requestId = req.id as string; + this.requestContext.delete(requestId); + } + + /** + * Returns the current request context data + * + * @returns {Record} - The current request context data + */ + public getRequestContext(): IRequestContextData { + return this.requestContext + } + + /** + * Sets the current request context data + * + * @param {Record>} context - The current request context data + * @returns {this} - The CurrentRequest class itself to enable chaining + */ + public setRequestContext(context: IRequestContextData): this { + this.requestContext = context; + return this; + } + + /** + * Returns the current ip context data + * + * @returns {IPContextData} - The current ip context data + */ + public getIpContext(): IPContextData { + return this.ipContext + } + + /** + * Sets the current ip context data + * + * @param {IPContextData} context - The current ip context data + * @returns {this} - The CurrentRequest class itself to enable chaining + */ + public setIpContext(context: IPContextData): this { + this.ipContext = context; + return this; + } + +} + +export default RequestContext \ No newline at end of file diff --git a/src/core/domains/express/services/RequestContextCleaner.ts b/src/core/domains/express/services/RequestContextCleaner.ts new file mode 100644 index 000000000..529e2323a --- /dev/null +++ b/src/core/domains/express/services/RequestContextCleaner.ts @@ -0,0 +1,94 @@ +import Singleton from "@src/core/base/Singleton"; +import { IPContextData, IPDatesArrayTTL } from "@src/core/domains/express/interfaces/ICurrentRequest"; +import { ICurrentRequestCleanUpConfig } from "@src/core/domains/express/interfaces/ICurrentRequestCleanUpConfig"; +import { App } from "@src/core/services/App"; + +/** + * A class that handles cleaning up expired items from the current IP context. + */ +class RequestContextCleaner extends Singleton { + + /** + * Starts the cleanup process. This will run an interval every N seconds specified in the config. + * If no delay is specified, it will default to 60 seconds. + * + * @param {ICurrentRequestCleanUpConfig} config The configuration for the cleanup process. + */ + public static boot(config: ICurrentRequestCleanUpConfig) { + const instance = this.getInstance(); + + const delayInSeconds = config.delayInSeconds ?? 60; + + setInterval(() => { + instance.scan(); + }, delayInSeconds * 1000); + } + + /** + * Scans the current IP context and removes expired items from it. + * This is done by checking the TTL of each item in the context and + * removing the ones that have expired. If the context is empty after + * removing expired items, it is removed from the store as well. + */ + scan() { + // Get the entire current IP context + let context = App.container('requestContext').getIpContext() as IPContextData; + + // Loop through the context and handle each IP + for(const [ip, ipContext] of context.entries()) { + context = this.handleIpContext(ip, ipContext, context); + } + + // Set the updated IP context + App.container('requestContext').setIpContext(context); + } + + /** + * Handles a single IP context by removing expired items from it. + * This is done by checking the TTL of each item in the context and + * removing the ones that have expired. If the context is empty after + * removing expired items, it is removed from the store as well. + * + * @param {string} ip - The IP address of the context to handle. + * @param {Map>} ipContext - The IP context to handle. + * @param {IPContextData} context - The current IP context. + * @returns {IPContextData} - The updated IP context. + */ + protected handleIpContext(ip: string, ipContext: Map>, context: IPContextData): IPContextData { + const now = new Date(); + + // Loop through the IP context and remove expired items + for(const [key, item] of ipContext.entries()) { + + // If the TTL is not a number, skip this item + if(typeof item.ttlSeconds !== 'number') continue; + + // Check if the dates are in the past, remove them if true. + for(const date of item.value) { + + const expiresAt = new Date(date); + expiresAt.setSeconds(expiresAt.getSeconds() + item.ttlSeconds); + + // Remove expired items + if(now > expiresAt) { + ipContext.delete(key); + } + + // Update size + context.set(ip, ipContext) + } + + // If the context is empty, remove it from the store + if(context.size === 0) { + console.log('Removed ipContext', ip) + context.delete(ip) + } + } + + // Return the updated IP context + return context + } + +} + +export default RequestContextCleaner \ No newline at end of file diff --git a/src/core/interfaces/ICoreContainers.ts b/src/core/interfaces/ICoreContainers.ts index 867a15319..f7d691fd4 100644 --- a/src/core/interfaces/ICoreContainers.ts +++ b/src/core/interfaces/ICoreContainers.ts @@ -2,7 +2,7 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; import ICommandService from '@src/core/domains/console/interfaces/ICommandService'; import { IDatabaseService } from '@src/core/domains/database/interfaces/IDatabaseService'; import { IEventService } from '@src/core/domains/events/interfaces/IEventService'; -import { ICurrentRequest } from '@src/core/domains/express/interfaces/ICurrentRequest'; +import { IRequestContext } from '@src/core/domains/express/interfaces/ICurrentRequest'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; import IValidatorService from '@src/core/domains/validator/interfaces/IValidatorService'; import readline from 'node:readline'; @@ -35,10 +35,10 @@ export interface ICoreContainers { express: IExpressService; /** - * Current Request Service + * Request Context service * Provided by '@src/core/domains/express/providers/ExpressProvider' */ - currentRequest: ICurrentRequest; + requestContext: IRequestContext; /** * Console service From de42ecf7a55886b05ab75bba45ede438f589abce Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 17:18:26 +0100 Subject: [PATCH 22/76] Updated securityMiddleware comments --- .../express/middleware/securityMiddleware.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index f10fc77bc..1bb5ba809 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -100,11 +100,17 @@ const applyRateLimitSecurity = async (req: BaseRequest, res: Response): Promise< } /** - * This middleware will check the security definition of the route and validate it. - * If the security definition is not valid, it will throw an UnauthorizedError. + * Security middleware for Express routes. + * + * This middleware check for defined security rules defined on the route. + * - Authorized (allow continue of processing, this is particular useful for RouteResource actions) + * - Authorized throw exceptions (Returns a 401 immediately if authorization fails) + * - Checks rate limits + * - Check authorized scopes + * - Check authorized roles * - * @param {{ route: IRoute }} - The route object - * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + * @param {IRoute} route - The Express route + * @return {(req: BaseRequest, res: Response, next: NextFunction) => Promise} */ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { try { @@ -146,7 +152,7 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req } /** - * Security is OK, continue + * All security checks have passed */ next(); } From 84a8cceff09fba7daaf23dd8af41a9d6277404a9 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 22:39:36 +0100 Subject: [PATCH 23/76] Updated Observer with awaitable methods --- src/core/base/Model.ts | 16 ++++++++------ .../domains/observer/interfaces/IObserver.ts | 20 ++++++++--------- .../observer/interfaces/IWithObserve.ts | 4 ++-- .../domains/observer/services/Observer.ts | 22 +++++++++---------- .../domains/observer/services/WithObserver.ts | 6 ++--- 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 0221b7fa2..6020c5e5c 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -149,7 +149,9 @@ export default abstract class Model extends WithObserve } if (Object.keys(this.observeProperties).includes(key as string)) { - this.data = this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.data); + this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.data).then((data) => { + this.data = data; + }) } } @@ -245,22 +247,22 @@ export default abstract class Model extends WithObserve */ async save(): Promise { if (this.data && !this.getId()) { - this.data = this.observeData('creating', this.data); + this.data = await this.observeData('creating', this.data); this.setTimestamp('createdAt'); this.setTimestamp('updatedAt'); this.data = await this.getDocumentManager().insertOne(this.prepareDocument()); await this.refresh(); - this.data = this.observeData('created', this.data); + this.data = await this.observeData('created', this.data); return; } - this.data = this.observeData('updating', this.data); + this.data = await this.observeData('updating', this.data); this.setTimestamp('updatedAt'); await this.update(); await this.refresh(); - this.data = this.observeData('updated', this.data); + this.data = await this.observeData('updated', this.data); } /** @@ -270,10 +272,10 @@ export default abstract class Model extends WithObserve */ async delete(): Promise { if (!this.data) return; - this.data = this.observeData('deleting', this.data); + this.data = await this.observeData('deleting', this.data); await this.getDocumentManager().deleteOne(this.data); this.data = null; - this.observeData('deleted', this.data); + await this.observeData('deleted', this.data); } /** diff --git a/src/core/domains/observer/interfaces/IObserver.ts b/src/core/domains/observer/interfaces/IObserver.ts index 481783490..3d6a7ef49 100644 --- a/src/core/domains/observer/interfaces/IObserver.ts +++ b/src/core/domains/observer/interfaces/IObserver.ts @@ -2,14 +2,14 @@ export type IObserverEvent = keyof IObserver; export interface IObserver { - creating(data: ReturnType): ReturnType; - created(data: ReturnType): ReturnType; - updating(data: ReturnType): ReturnType; - updated(data: ReturnType): ReturnType; - saving(data: ReturnType): ReturnType; - saved(data: ReturnType): ReturnType; - deleting(data: ReturnType): ReturnType; - deleted(data: ReturnType): ReturnType; - on(name: IObserverEvent, data: ReturnType): ReturnType; - onCustom(customName: string, data: ReturnType): ReturnType; + creating(data: ReturnType): Promise; + created(data: ReturnType): Promise; + updating(data: ReturnType): Promise; + updated(data: ReturnType): Promise; + saving(data: ReturnType): Promise; + saved(data: ReturnType): Promise; + deleting(data: ReturnType): Promise; + deleted(data: ReturnType): Promise; + on(name: IObserverEvent, data: ReturnType): Promise; + onCustom(customName: string, data: ReturnType): Promise; } \ No newline at end of file diff --git a/src/core/domains/observer/interfaces/IWithObserve.ts b/src/core/domains/observer/interfaces/IWithObserve.ts index e08fcf2ef..f934a7814 100644 --- a/src/core/domains/observer/interfaces/IWithObserve.ts +++ b/src/core/domains/observer/interfaces/IWithObserve.ts @@ -22,12 +22,12 @@ export default interface IWithObserve; /** * Call an observer event method * [usage] * [class extends IWithObserve].observer.onCustom('someCustomMethod', data) */ - observeDataCustom?(customName: keyof Observer, data: any): ReturnType; + observeDataCustom?(customName: keyof Observer, data: any): Promise; } \ No newline at end of file diff --git a/src/core/domains/observer/services/Observer.ts b/src/core/domains/observer/services/Observer.ts index c6bf73c7d..8ba588f0f 100644 --- a/src/core/domains/observer/services/Observer.ts +++ b/src/core/domains/observer/services/Observer.ts @@ -24,7 +24,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -34,7 +34,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -44,7 +44,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -54,7 +54,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -64,7 +64,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -74,7 +74,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -84,7 +84,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -94,7 +94,7 @@ export default abstract class Observer implements IObserver { return data; } @@ -111,11 +111,11 @@ export default abstract class Observer implements IObserver { if (this[name] && typeof this[name] === 'function') { // Call the method associated with the event name // eslint-disable-next-line no-unused-vars - return (this[name] as (data: ReturnType, ...args: any[]) => ReturnType)(data); + return await (this[name] as (data: ReturnType, ...args: any[]) => ReturnType)(data); } // If no method is found or it's not a function, return the original data return data; @@ -133,7 +133,7 @@ export default abstract class Observer implements IObserver { // Attempt to find a method on this instance with the given custom name // eslint-disable-next-line no-unused-vars const method = this[customName as keyof this] as ((data: ReturnType, ...args: any[]) => ReturnType) | undefined; diff --git a/src/core/domains/observer/services/WithObserver.ts b/src/core/domains/observer/services/WithObserver.ts index 284294a59..a6019190b 100644 --- a/src/core/domains/observer/services/WithObserver.ts +++ b/src/core/domains/observer/services/WithObserver.ts @@ -37,11 +37,11 @@ export abstract class WithObserver implements IWithObserve { if(!this.observer) { return data } - return this.observer.on(name, data) + return await this.observer.on(name, data) } /** @@ -54,7 +54,7 @@ export abstract class WithObserver implements IWithObserve { if(!this.observer) { return data } From 0b5f650417e06fe5904fe32585baf31ea3656983 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 22:39:54 +0100 Subject: [PATCH 24/76] Auth permisisons (progress) --- src/app/migrations/2024-09-06-create-user-table.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/migrations/2024-09-06-create-user-table.ts b/src/app/migrations/2024-09-06-create-user-table.ts index a7b256ec1..87c0252dc 100644 --- a/src/app/migrations/2024-09-06-create-user-table.ts +++ b/src/app/migrations/2024-09-06-create-user-table.ts @@ -1,6 +1,6 @@ +import User from "@src/app/models/auth/User"; import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -import User from "@src/app/models/auth/User"; export class CreateUserModelMigration extends BaseMigration { @@ -22,6 +22,7 @@ export class CreateUserModelMigration extends BaseMigration { await this.schema.createTable(this.table, { email: DataTypes.STRING, hashedPassword: DataTypes.STRING, + group: DataTypes.JSON, roles: DataTypes.JSON, firstName: stringNullable, lastName: stringNullable, From 4efa054935a559aaf99e3c49b9d78e0bccdf7857 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 22:40:01 +0100 Subject: [PATCH 25/76] Missed files --- src/app/models/auth/User.ts | 42 +++++++++++++-- src/app/observers/UserObserver.ts | 30 ++++++++++- src/config/auth.ts | 51 +++++++++++++++++++ src/core/domains/auth/actions/create.ts | 8 +-- src/core/domains/auth/actions/update.ts | 3 +- src/core/domains/auth/factory/userFactory.ts | 3 +- .../domains/auth/interfaces/IAuthConfig.ts | 3 ++ .../domains/auth/interfaces/IAuthService.ts | 4 +- .../auth/interfaces/IPermissionsConfig.ts | 14 +++++ .../domains/auth/interfaces/IUserModel.ts | 14 +---- .../domains/make/base/BaseMakeFileCommand.ts | 8 +-- 11 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 src/core/domains/auth/interfaces/IPermissionsConfig.ts diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index 629edc47f..fd1a4a40d 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -1,7 +1,23 @@ import ApiToken from "@src/app/models/auth/ApiToken"; import UserObserver from "@src/app/observers/UserObserver"; import Model from "@src/core/base/Model"; -import IUserModel, { IUserData } from "@src/core/domains/auth/interfaces/IUserModel"; +import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; +import IModelData from "@src/core/interfaces/IModelData"; + +/** + * User structure + */ +export interface IUserData extends IModelData { + email: string; + password?: string; + hashedPassword: string; + roles: string[]; + groups: string[]; + firstName?: string; + lastName?: string; + createdAt?: Date; + updatedAt?: Date; +} /** * User model @@ -56,6 +72,7 @@ export default class User extends Model implements IUserModel { * These fields will be returned as JSON when the model is serialized. */ json = [ + 'groups', 'roles' ] @@ -70,12 +87,27 @@ export default class User extends Model implements IUserModel { const userRoles = this.getAttribute('roles') ?? []; for(const role of roles) { - if(userRoles.includes(role)) { - return true; - } + if(!userRoles.includes(role)) return false; + } + + return true; + } + + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasGroup(groups: string | string[]): boolean { + groups = typeof groups === 'string' ? [groups] : groups; + const userGroups = this.getAttribute('groups') ?? []; + + for(const group of groups) { + if(!userGroups.includes(group)) return false; } - return false + return true; } /** diff --git a/src/app/observers/UserObserver.ts b/src/app/observers/UserObserver.ts index 83483f778..03dc7c6cd 100644 --- a/src/app/observers/UserObserver.ts +++ b/src/app/observers/UserObserver.ts @@ -1,6 +1,8 @@ -import { IUserData } from "@src/core/domains/auth/interfaces/IUserModel"; import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import Observer from "@src/core/domains/observer/services/Observer"; +import { App } from "@src/core/services/App"; + +import { IUserData } from "../models/auth/User"; /** * Observer for the User model. @@ -15,8 +17,32 @@ export default class UserObserver extends Observer { * @param data The User data being created. * @returns The processed User data. */ - creating(data: IUserData): IUserData { + async creating(data: IUserData): Promise { data = this.onPasswordChange(data) + data = await this.updateRoles(data) + return data + } + + /** + * Updates the roles of the user based on the groups they belong to. + * Retrieves the roles associated with each group the user belongs to from the permissions configuration. + * @param data The User data being created/updated. + * @returns The processed User data with the updated roles. + */ + async updateRoles(data: IUserData): Promise { + let updatedRoles: string[] = []; + + for(const group of data.groups) { + const relatedRoles = App.container('auth').config.permissions.groups.find(g => g.name === group)?.roles ?? []; + + updatedRoles = [ + ...updatedRoles, + ...relatedRoles + ] + } + + data.roles = updatedRoles + return data } diff --git a/src/config/auth.ts b/src/config/auth.ts index b1e6c4648..18435d14b 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -8,6 +8,25 @@ import { IAuthConfig } from '@src/core/domains/auth/interfaces/IAuthConfig'; import AuthService from '@src/core/domains/auth/services/AuthService'; import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; +/** + * Available groups + */ +export const GROUPS = { + User: 'user', + Admin: 'admin', +} as const + +/** + * Available roles + */ +export const ROLES = { + USER: 'user', + ADMIN: 'admin' +} as const + +/** + * Auth configuration + */ const config: IAuthConfig = { service: { authService: AuthService @@ -39,6 +58,38 @@ const config: IAuthConfig = { * Enable or disable create a new user endpoint */ enableAuthRoutesAllowCreate: parseBooleanFromString(process.env.ENABLE_AUTH_ROUTES_ALLOW_CREATE, 'true'), + + /** + * Permissions configuration + * - user.defaultGroup will be the default group for new user accounts + * - groups can be added to the user property 'groups' + * these will automatically populate the roles property + */ + permissions: { + + /** + * The default user group + */ + user: { + defaultGroup: GROUPS.User, + }, + + /** + * The list of groups + */ + groups: [ + { + name: GROUPS.User, + roles: [ROLES.USER], + scopes: [] + }, + { + name: GROUPS.Admin, + roles: [ROLES.ADMIN], + scopes: [] + } + ] + } } export default config; \ No newline at end of file diff --git a/src/core/domains/auth/actions/create.ts b/src/core/domains/auth/actions/create.ts index ee23b1e08..1898836c4 100644 --- a/src/core/domains/auth/actions/create.ts +++ b/src/core/domains/auth/actions/create.ts @@ -1,6 +1,5 @@ -import Roles from '@src/core/domains/auth/enums/RolesEnum'; +import { IUserData } from '@src/app/models/auth/User'; import UserFactory from '@src/core/domains/auth/factory/userFactory'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; import hashPassword from '@src/core/domains/auth/utils/hashPassword'; import responseError from '@src/core/domains/express/requests/responseError'; import ValidationError from '@src/core/exceptions/ValidationError'; @@ -33,7 +32,10 @@ export default async (req: Request, res: Response): Promise => { email, password, hashedPassword: hashPassword(password ?? ''), - roles: [Roles.USER], + groups: [ + App.container('auth').config.permissions.user.defaultGroup + ], + roles: [], firstName, lastName }); diff --git a/src/core/domains/auth/actions/update.ts b/src/core/domains/auth/actions/update.ts index f77acc69f..1880ed77f 100644 --- a/src/core/domains/auth/actions/update.ts +++ b/src/core/domains/auth/actions/update.ts @@ -1,5 +1,4 @@ -import User from '@src/app/models/auth/User'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; +import User, { IUserData } from '@src/app/models/auth/User'; import hashPassword from '@src/core/domains/auth/utils/hashPassword'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; diff --git a/src/core/domains/auth/factory/userFactory.ts b/src/core/domains/auth/factory/userFactory.ts index e91fed6b4..b9e084756 100644 --- a/src/core/domains/auth/factory/userFactory.ts +++ b/src/core/domains/auth/factory/userFactory.ts @@ -1,6 +1,5 @@ -import User from '@src/app/models/auth/User'; +import User, { IUserData } from '@src/app/models/auth/User'; import Factory from '@src/core/base/Factory'; -import { IUserData } from '@src/core/domains/auth/interfaces/IUserModel'; /** * Factory for creating User models. diff --git a/src/core/domains/auth/interfaces/IAuthConfig.ts b/src/core/domains/auth/interfaces/IAuthConfig.ts index f0298e1b8..a37c91410 100644 --- a/src/core/domains/auth/interfaces/IAuthConfig.ts +++ b/src/core/domains/auth/interfaces/IAuthConfig.ts @@ -8,6 +8,8 @@ import { ModelConstructor } from "@src/core/interfaces/IModel"; import { RepositoryConstructor } from "@src/core/interfaces/IRepository"; import { ServiceConstructor } from "@src/core/interfaces/IService"; +import { IPermissionsConfig } from "./IPermissionsConfig"; + export interface IAuthConfig { service: { authService: ServiceConstructor; @@ -27,4 +29,5 @@ export interface IAuthConfig { jwtSecret: string, enableAuthRoutes: boolean; enableAuthRoutesAllowCreate: boolean; + permissions: IPermissionsConfig; } \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index bbce1c2bd..b6f2e98ef 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -6,6 +6,8 @@ import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import IService from "@src/core/interfaces/IService"; +import { IAuthConfig } from "./IAuthConfig"; + /** * The service that handles authentication. @@ -22,7 +24,7 @@ export interface IAuthService extends IService { * @type {any} * @memberof IAuthService */ - config: any; + config: IAuthConfig; /** * The user repository diff --git a/src/core/domains/auth/interfaces/IPermissionsConfig.ts b/src/core/domains/auth/interfaces/IPermissionsConfig.ts new file mode 100644 index 000000000..cbc7af21c --- /dev/null +++ b/src/core/domains/auth/interfaces/IPermissionsConfig.ts @@ -0,0 +1,14 @@ +export interface IPermissionGroup { + name: string; + scopes?: string[]; + roles?: string[]; +} + +export interface IPermissionUser { + defaultGroup: string; +} + +export interface IPermissionsConfig { + user: IPermissionUser; + groups: IPermissionGroup[] +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IUserModel.ts b/src/core/domains/auth/interfaces/IUserModel.ts index 90c12efd3..96c3c23b9 100644 --- a/src/core/domains/auth/interfaces/IUserModel.ts +++ b/src/core/domains/auth/interfaces/IUserModel.ts @@ -1,19 +1,9 @@ /* eslint-disable no-unused-vars */ +import { IUserData } from "@src/app/models/auth/User"; import { IModel } from "@src/core/interfaces/IModel"; -import IModelData from "@src/core/interfaces/IModelData"; - -export interface IUserData extends IModelData { - email: string - password?: string; - hashedPassword: string - roles: string[], - firstName?: string; - lastName?: string; - createdAt?: Date, - updatedAt?: Date, -} export default interface IUserModel extends IModel { tokens(...args: any[]): Promise; hasRole(...args: any[]): any; + hasGroup(...args: any[]): any; } \ No newline at end of file diff --git a/src/core/domains/make/base/BaseMakeFileCommand.ts b/src/core/domains/make/base/BaseMakeFileCommand.ts index 2684f8dc5..c79252acf 100644 --- a/src/core/domains/make/base/BaseMakeFileCommand.ts +++ b/src/core/domains/make/base/BaseMakeFileCommand.ts @@ -66,11 +66,13 @@ export default class BaseMakeFileCommand extends BaseCommand { } // Set a default collection, if required - this.makeFileArguments = this.argumentObserver.onCustom('setDefaultCollection', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setDefaultCollection', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); + // Set name the name (lower or upper depending on options) - this.makeFileArguments = this.argumentObserver.onCustom('setName', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setName', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); + // Ensure the file ends with the specified value - this.makeFileArguments = this.argumentObserver.onCustom('setEndsWith', this.makeFileArguments, this.options); + this.argumentObserver.onCustom('setEndsWith', this.makeFileArguments, this.options).then(data => this.makeFileArguments = data); this.setOverwriteArg('name', this.makeFileArguments.name); From 442ac8a4494c67ed225073de125b6ab1ca2943ee Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 26 Sep 2024 23:00:22 +0100 Subject: [PATCH 26/76] fix(migrationss): Fix migration failing when file missing --- .../services/MigrationFilesService.ts | 3 +- .../migrations/services/MigrationService.ts | 69 +++++++++++-------- src/core/exceptions/FileNotFoundError.ts | 8 +++ 3 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 src/core/exceptions/FileNotFoundError.ts diff --git a/src/core/domains/migrations/services/MigrationFilesService.ts b/src/core/domains/migrations/services/MigrationFilesService.ts index 41d6dad44..84d62bd2e 100644 --- a/src/core/domains/migrations/services/MigrationFilesService.ts +++ b/src/core/domains/migrations/services/MigrationFilesService.ts @@ -1,4 +1,5 @@ import { IMigration } from '@src/core/domains/migrations/interfaces/IMigration'; +import FileNotFoundError from '@src/core/exceptions/FileNotFoundError'; import checksumFile from '@src/core/util/checksum'; import Str from '@src/core/util/str/Str'; import fs from 'fs'; @@ -43,7 +44,7 @@ class MigrationFileService { const absolutePath = path.resolve(this.appMigrationsDir, fileName); if(!fs.existsSync(absolutePath)) { - throw new Error(`File ${absolutePath} does not exist`); + throw new FileNotFoundError(`File ${absolutePath} does not exist`); } const importedModule = await import(absolutePath); diff --git a/src/core/domains/migrations/services/MigrationService.ts b/src/core/domains/migrations/services/MigrationService.ts index c786ad089..1adc3cad5 100644 --- a/src/core/domains/migrations/services/MigrationService.ts +++ b/src/core/domains/migrations/services/MigrationService.ts @@ -6,6 +6,7 @@ import MigrationRepository from "@src/core/domains/migrations/repository/Migrati import createMongoDBSchema from "@src/core/domains/migrations/schema/createMongoDBSchema"; import createPostgresSchema from "@src/core/domains/migrations/schema/createPostgresSchema"; import MigrationFileService from "@src/core/domains/migrations/services/MigrationFilesService"; +import FileNotFoundError from "@src/core/exceptions/FileNotFoundError"; import { App } from "@src/core/services/App"; interface MigrationDetail { @@ -33,7 +34,7 @@ class MigrationService implements IMigrationService { } async boot() { - // Create the migrations schema + // Create the migrations schema await this.createSchema(); } @@ -45,19 +46,26 @@ class MigrationService implements IMigrationService { const result: MigrationDetail[] = []; const migrationFileNames = this.fileService.getMigrationFileNames(); - - for(const fileName of migrationFileNames) { - const migration = await this.fileService.getImportMigrationClass(fileName); - if(filterByFileName && fileName !== filterByFileName) { - continue; - } + for (const fileName of migrationFileNames) { + try { + const migration = await this.fileService.getImportMigrationClass(fileName); - if(group && migration.group !== group) { - continue; - } + if (filterByFileName && fileName !== filterByFileName) { + continue; + } - result.push({fileName, migration}); + if (group && migration.group !== group) { + continue; + } + + result.push({ fileName, migration }); + } + catch (err) { + if (err instanceof FileNotFoundError) { + continue; + } + } } return result; @@ -77,7 +85,7 @@ class MigrationService implements IMigrationService { const aDate = this.fileService.parseDate(a.fileName); const bDate = this.fileService.parseDate(b.fileName); - if(!aDate || !bDate) { + if (!aDate || !bDate) { return 0; } @@ -87,14 +95,14 @@ class MigrationService implements IMigrationService { // Get the current batch count const newBatchCount = (await this.getCurrentBatchCount()) + 1; - if(!migrationsDetails.length) { + if (!migrationsDetails.length) { console.log('[Migration] No migrations to run'); } // Run the migrations for every file for (const migrationDetail of migrationsDetails) { console.log('[Migration] up -> ' + migrationDetail.fileName); - + await this.handleFileUp(migrationDetail, newBatchCount); } } @@ -104,7 +112,7 @@ class MigrationService implements IMigrationService { */ async down({ batch }: Pick): Promise { // Get the current batch count - let batchCount = typeof batch !== 'undefined' ? batch : await this.getCurrentBatchCount(); + let batchCount = typeof batch !== 'undefined' ? batch : await this.getCurrentBatchCount(); batchCount = isNaN(batchCount) ? 1 : batchCount; // Get the migration results @@ -117,28 +125,35 @@ class MigrationService implements IMigrationService { const aDate = a.getAttribute('appliedAt') as Date; const bDate = b.getAttribute('appliedAt') as Date; - if(!aDate || !bDate) { + if (!aDate || !bDate) { return 0; } return aDate.getTime() - bDate.getTime(); }); - if(!results.length) { + if (!results.length) { console.log('[Migration] No migrations to run'); } // Run the migrations - for(const result of results) { - const fileName = result.getAttribute('name') as string; - const migration = await this.fileService.getImportMigrationClass(fileName); + for (const result of results) { + try { + const fileName = result.getAttribute('name') as string; + const migration = await this.fileService.getImportMigrationClass(fileName); - // Run the down method - console.log(`[Migration] down -> ${fileName}`); - await migration.down(); + // Run the down method + console.log(`[Migration] down -> ${fileName}`); + await migration.down(); - // Delete the migration document - await result.delete(); + // Delete the migration document + await result.delete(); + } + catch (err) { + if (err instanceof FileNotFoundError) { + continue; + } + } } } @@ -163,7 +178,7 @@ class MigrationService implements IMigrationService { return; } - if(!migration.shouldUp()) { + if (!migration.shouldUp()) { console.log(`[Migration] Skipping (Provider mismatch) -> ${fileName}`); return; } @@ -225,7 +240,7 @@ class MigrationService implements IMigrationService { /** * Handle Postgres driver */ - if(App.container('db').isProvider('postgres')) { + if (App.container('db').isProvider('postgres')) { await createPostgresSchema(); } } diff --git a/src/core/exceptions/FileNotFoundError.ts b/src/core/exceptions/FileNotFoundError.ts new file mode 100644 index 000000000..ca761a770 --- /dev/null +++ b/src/core/exceptions/FileNotFoundError.ts @@ -0,0 +1,8 @@ +export default class FileNotFoundError extends Error { + + constructor(message: string = 'File not found') { + super(message); + this.name = 'FileNotFoundError'; + } + +} \ No newline at end of file From 7789d43125f37bb162027c4cb66fdfabaa82fe0c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 16:38:30 +0100 Subject: [PATCH 27/76] Added permission groups, user groups and roles, ApiToken scopes, RouteResource scopes, renamed property enableScopes --- .../2024-09-06-create-user-table.ts | 2 +- src/app/models/auth/ApiToken.ts | 13 ++++ src/app/models/auth/User.ts | 3 +- src/app/observers/ApiTokenObserver.ts | 53 +++++++++++++++ src/app/observers/UserObserver.ts | 3 +- src/config/auth.ts | 28 ++++++-- src/core/base/Model.ts | 4 ++ .../domains/auth/interfaces/IAuthConfig.ts | 3 +- .../domains/auth/interfaces/IAuthService.ts | 3 +- src/core/domains/auth/interfaces/IScope.ts | 1 + src/core/domains/auth/services/ModelScopes.ts | 37 ++++++++++ src/core/domains/auth/services/Scopes.ts | 9 +++ .../{resourceAction.ts => baseAction.ts} | 0 src/core/domains/express/interfaces/IRoute.ts | 2 +- .../interfaces/IRouteResourceOptions.ts | 3 +- .../domains/express/interfaces/ISecurity.ts | 2 +- .../domains/express/routing/RouteResource.ts | 68 ++++++++----------- .../express/routing/RouteResourceScope.ts | 37 +++++----- .../express/services/ExpressService.ts | 6 +- src/core/domains/express/services/Security.ts | 11 +++ .../express/services/SecurityReader.ts | 3 +- .../domains/express/services/SecurityRules.ts | 13 ++++ .../make/templates/Observer.ts.template | 12 ++-- src/core/interfaces/IModel.ts | 1 - 24 files changed, 233 insertions(+), 84 deletions(-) create mode 100644 src/app/observers/ApiTokenObserver.ts create mode 100644 src/core/domains/auth/interfaces/IScope.ts create mode 100644 src/core/domains/auth/services/ModelScopes.ts create mode 100644 src/core/domains/auth/services/Scopes.ts rename src/core/domains/express/actions/{resourceAction.ts => baseAction.ts} (100%) diff --git a/src/app/migrations/2024-09-06-create-user-table.ts b/src/app/migrations/2024-09-06-create-user-table.ts index 87c0252dc..eec4cbb79 100644 --- a/src/app/migrations/2024-09-06-create-user-table.ts +++ b/src/app/migrations/2024-09-06-create-user-table.ts @@ -22,7 +22,7 @@ export class CreateUserModelMigration extends BaseMigration { await this.schema.createTable(this.table, { email: DataTypes.STRING, hashedPassword: DataTypes.STRING, - group: DataTypes.JSON, + groups: DataTypes.JSON, roles: DataTypes.JSON, firstName: stringNullable, lastName: stringNullable, diff --git a/src/app/models/auth/ApiToken.ts b/src/app/models/auth/ApiToken.ts index 708f65804..e4ad8034f 100644 --- a/src/app/models/auth/ApiToken.ts +++ b/src/app/models/auth/ApiToken.ts @@ -1,4 +1,5 @@ import User from '@src/app/models/auth/User'; +import ApiTokenObserver from '@src/app/observers/ApiTokenObserver'; import Model from '@src/core/base/Model'; import IApiTokenModel, { IApiTokenData } from '@src/core/domains/auth/interfaces/IApitokenModel'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; @@ -28,6 +29,18 @@ class ApiToken extends Model implements IApiTokenModel { 'scopes' ] + /** + * Construct an ApiToken model from the given data. + * + * @param {IApiTokenData} [data=null] The data to construct the model from. + * + * @constructor + */ + constructor(data: IApiTokenData | null = null) { + super(data) + this.observeWith(ApiTokenObserver) + } + /** * Disable createdAt and updatedAt timestamps */ diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index fd1a4a40d..6558eda4a 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -47,7 +47,8 @@ export default class User extends Model implements IUserModel { guarded: string[] = [ 'hashedPassword', 'password', - 'roles' + 'roles', + 'groups', ]; /** diff --git a/src/app/observers/ApiTokenObserver.ts b/src/app/observers/ApiTokenObserver.ts new file mode 100644 index 000000000..1c281a333 --- /dev/null +++ b/src/app/observers/ApiTokenObserver.ts @@ -0,0 +1,53 @@ +import UserRepository from "@src/app/repositories/auth/UserRepository"; +import { IApiTokenData } from "@src/core/domains/auth/interfaces/IApitokenModel"; +import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; +import Observer from "@src/core/domains/observer/services/Observer"; +import { App } from "@src/core/services/App"; + +interface IApiTokenObserverData extends IApiTokenData { + +} + +export default class ApiTokenObserver extends Observer { + + protected readonly userRepository = new UserRepository(); + + /** + * Called when a data object is being created. + * @param data The model data being created. + * @returns The processed model data. + */ + async creating(data: IApiTokenObserverData): Promise { + data = await this.addGroupScopes(data) + return data + } + + /** + * Adds scopes from groups the user is a member of to the scopes of the ApiToken being created. + * @param data The ApiToken data being created. + * @returns The ApiToken data with the added scopes. + */ + + async addGroupScopes(data: IApiTokenObserverData): Promise { + const user = await this.userRepository.findById(data.userId) as IUserModel; + + if(!user) { + return data + } + + const userGroups = user.getAttribute('groups') ?? []; + + for(const userGroup of userGroups) { + const group = App.container('auth').config.permissions.groups.find(g => g.name === userGroup); + const scopes = group?.scopes ?? []; + + data.scopes = [ + ...data.scopes, + ...scopes + ] + } + + return data + } + +} \ No newline at end of file diff --git a/src/app/observers/UserObserver.ts b/src/app/observers/UserObserver.ts index 03dc7c6cd..daa6ce8c2 100644 --- a/src/app/observers/UserObserver.ts +++ b/src/app/observers/UserObserver.ts @@ -1,9 +1,8 @@ +import { IUserData } from "@src/app/models/auth/User"; import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import Observer from "@src/core/domains/observer/services/Observer"; import { App } from "@src/core/services/App"; -import { IUserData } from "../models/auth/User"; - /** * Observer for the User model. * diff --git a/src/config/auth.ts b/src/config/auth.ts index 18435d14b..4db9920e9 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -12,16 +12,16 @@ import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; * Available groups */ export const GROUPS = { - User: 'user', - Admin: 'admin', + User: 'group_user', + Admin: 'group_admin', } as const /** * Available roles */ export const ROLES = { - USER: 'user', - ADMIN: 'admin' + USER: 'role_user', + ADMIN: 'role_admin' } as const /** @@ -61,9 +61,23 @@ const config: IAuthConfig = { /** * Permissions configuration - * - user.defaultGroup will be the default group for new user accounts - * - groups can be added to the user property 'groups' - * these will automatically populate the roles property + * - user.defaultGroup - The group will be the default group for new user accounts + * - groups - The list of groups + * - groups.roles will auto populate the roles on creation + * - groups.scopes will be automatically added to new ApiTokenModels + * + * + * You can use ModelScopes to generate scopes for models. These scopes will be + * added to any routes created with RouteResource. For example: + * + * Example: + * { + * name: GROUPS.User, + * roles: [ROLES.USER], + * scopes: [ + * ...ModelScopes.getScopes(ExampleModel, ['read', 'write']) + * ] + * }, */ permissions: { diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 6020c5e5c..4b8ff0c32 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -9,6 +9,7 @@ import IModelData from '@src/core/interfaces/IModelData'; import { App } from '@src/core/services/App'; import Str from '@src/core/util/str/Str'; + /** * Abstract base class for database models. * Extends WithObserver to provide observation capabilities. @@ -18,6 +19,8 @@ import Str from '@src/core/util/str/Str'; */ export default abstract class Model extends WithObserver implements IModel { + public name!: string; + /** * The name of the database connection to use. * Defaults to the application's default connection name. @@ -86,6 +89,7 @@ export default abstract class Model extends WithObserve constructor(data: Data | null) { super(); this.data = data; + this.name = this.constructor.name; this.setDefaultTable(); } diff --git a/src/core/domains/auth/interfaces/IAuthConfig.ts b/src/core/domains/auth/interfaces/IAuthConfig.ts index a37c91410..cef0f4f9d 100644 --- a/src/core/domains/auth/interfaces/IAuthConfig.ts +++ b/src/core/domains/auth/interfaces/IAuthConfig.ts @@ -1,6 +1,7 @@ import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; import { IAuthService } from "@src/core/domains/auth/interfaces/IAuthService"; +import { IPermissionsConfig } from "@src/core/domains/auth/interfaces/IPermissionsConfig"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; import { IInterfaceCtor } from "@src/core/domains/validator/interfaces/IValidator"; @@ -8,8 +9,6 @@ import { ModelConstructor } from "@src/core/interfaces/IModel"; import { RepositoryConstructor } from "@src/core/interfaces/IRepository"; import { ServiceConstructor } from "@src/core/interfaces/IService"; -import { IPermissionsConfig } from "./IPermissionsConfig"; - export interface IAuthConfig { service: { authService: ServiceConstructor; diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index b6f2e98ef..939c24a24 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,13 +1,12 @@ /* eslint-disable no-unused-vars */ import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; +import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import IService from "@src/core/interfaces/IService"; -import { IAuthConfig } from "./IAuthConfig"; - /** * The service that handles authentication. diff --git a/src/core/domains/auth/interfaces/IScope.ts b/src/core/domains/auth/interfaces/IScope.ts new file mode 100644 index 000000000..a1fe0a73b --- /dev/null +++ b/src/core/domains/auth/interfaces/IScope.ts @@ -0,0 +1 @@ +export type Scope = 'read' | 'write' | 'create' | 'delete' | 'all'; \ No newline at end of file diff --git a/src/core/domains/auth/services/ModelScopes.ts b/src/core/domains/auth/services/ModelScopes.ts new file mode 100644 index 000000000..9a28c16dc --- /dev/null +++ b/src/core/domains/auth/services/ModelScopes.ts @@ -0,0 +1,37 @@ +import { Scope } from "@src/core/domains/auth/interfaces/IScope"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; + +class ModelScopes { + + /** + * Generates a list of scopes, given a model name and some scope types. + * + * Available Scopes + * - all - All scopes + * - read - Read scopes + * - write - Write scopes + * - update - Update scopes + * - delete - Delete scopes + * - create - Create scopes + * + * @param model The model as a constructor + * @param scopes The scope type(s) to generate scopes for. If a string, it will be an array of only that type. + * @returns A list of scopes in the format of 'modelName:scopeType' + * + * Example: + * + * const scopes = ModelScopes.getScopes(BlogModel, ['write', 'read']) + * + * // Output + * [ + * 'BlogModel:write', + * 'BlogModel:read' + * ] + */ + public static getScopes(model: ModelConstructor, scopes: Scope[] = ['all'], additionalScopes: string[] = []): string[] { + return [...scopes.map((scope) => `${(model.name)}:${scope}`), ...additionalScopes]; + } + +} + +export default ModelScopes \ No newline at end of file diff --git a/src/core/domains/auth/services/Scopes.ts b/src/core/domains/auth/services/Scopes.ts new file mode 100644 index 000000000..99df79ad1 --- /dev/null +++ b/src/core/domains/auth/services/Scopes.ts @@ -0,0 +1,9 @@ +const Scopes = { + READ: 'read', + WRITE: 'write', + DELETE: 'delete', + CREATE: 'create', + ALL: 'all' +} as const; + +export default Scopes \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceAction.ts b/src/core/domains/express/actions/baseAction.ts similarity index 100% rename from src/core/domains/express/actions/resourceAction.ts rename to src/core/domains/express/actions/baseAction.ts diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index dc1f59cd9..0763031a7 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -10,7 +10,7 @@ export interface IRoute { action: IRouteAction; resourceType?: string; scopes?: string[]; - scopesSecurityEnabled?: boolean; + enableScopes?: boolean; middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 604cdb45c..78db1df93 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -3,6 +3,7 @@ import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfa import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; + export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; export interface IRouteResourceOptions extends Pick { @@ -14,5 +15,5 @@ export interface IRouteResourceOptions extends Pick { updateValidator?: ValidatorCtor; security?: IIdentifiableSecurityCallback[]; scopes?: string[]; - scopesSecurityEnabled?: boolean; + enableScopes?: boolean; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index 2ebf4a778..1eac4924c 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -23,7 +23,7 @@ export type IIdentifiableSecurityCallback = { id: string; // Include another security rule in the callback. // TODO: We could add another type here 'alsoArguments' if extra parameters are required - also?: string; + also?: string[] | string | null; // The condition for when the security check should be executed. Defaults to 'always'. when: string[] | null; // The condition for when the security check should never be executed. diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index a067abe82..9ce819d4c 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -1,4 +1,5 @@ -import resourceAction from "@src/core/domains/express/actions/resourceAction"; +import ModelScopes from "@src/core/domains/auth/services/ModelScopes"; +import baseAction from "@src/core/domains/express/actions/baseAction"; import resourceCreate from "@src/core/domains/express/actions/resourceCreate"; import resourceDelete from "@src/core/domains/express/actions/resourceDelete"; import resourceIndex from "@src/core/domains/express/actions/resourceIndex"; @@ -8,7 +9,7 @@ import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; -import RouteResourceScope from "@src/core/domains/express/routing/RouteResourceScope"; +import { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; /** @@ -36,30 +37,37 @@ export const RouteResourceTypes = { * @param options.except - An array of resource types to exclude from the routes * @param options.createValidator - A validator to use for the create route * @param options.updateValidator - A validator to use for the update route + * @param options.enableScopes - Enable scopes security for these routes * @returns A group of routes that can be used to handle requests for the resource */ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const name = options.name.startsWith('/') ? options.name.slice(1) : options.name const { + resource, scopes = [], - scopesSecurityEnabled = false + enableScopes: enableScopesOption } = options; + /** + * Check if scopes are enabled + */ + const hasEnableScopesSecurity = options.security?.find(security => security.id === SecurityIdentifiers.ENABLE_SCOPES); + const enableScopes = enableScopesOption ?? typeof hasEnableScopesSecurity !== 'undefined'; + + /** + * Define all the routes for the resource + */ const routes = RouteGroup([ // Get all resources Route({ name: `${name}.index`, resourceType: RouteResourceTypes.ALL, - scopes: RouteResourceScope.getScopes({ - name, - types: 'read', - additionalScopes: scopes - }), - scopesSecurityEnabled, + scopes: ModelScopes.getScopes(resource, ['read'], scopes), + enableScopes, method: 'get', path: `/${name}`, - action: resourceAction(options, resourceIndex), + action: baseAction(options, resourceIndex), middlewares: options.middlewares, security: options.security }), @@ -67,15 +75,11 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.show`, resourceType: RouteResourceTypes.SHOW, - scopes: RouteResourceScope.getScopes({ - name, - types: 'read', - additionalScopes: scopes - }), - scopesSecurityEnabled, + scopes: ModelScopes.getScopes(resource, ['read'], scopes), + enableScopes, method: 'get', path: `/${name}/:id`, - action: resourceAction(options, resourceShow), + action: baseAction(options, resourceShow), middlewares: options.middlewares, security: options.security }), @@ -83,15 +87,11 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.update`, resourceType: RouteResourceTypes.UPDATE, - scopes: RouteResourceScope.getScopes({ - name, - types: 'write', - additionalScopes: scopes - }), - scopesSecurityEnabled, + scopes: ModelScopes.getScopes(resource, ['write'], scopes), + enableScopes, method: 'put', path: `/${name}/:id`, - action: resourceAction(options, resourceUpdate), + action: baseAction(options, resourceUpdate), validator: options.updateValidator, middlewares: options.middlewares, security: options.security @@ -100,15 +100,11 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.destroy`, resourceType: RouteResourceTypes.DESTROY, - scopes: RouteResourceScope.getScopes({ - name, - types: 'delete', - additionalScopes: scopes - }), - scopesSecurityEnabled, + scopes: ModelScopes.getScopes(resource, ['delete'], scopes), + enableScopes, method: 'delete', path: `/${name}/:id`, - action: resourceAction(options, resourceDelete), + action: baseAction(options, resourceDelete), middlewares: options.middlewares, security: options.security }), @@ -116,15 +112,11 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.create`, resourceType: RouteResourceTypes.CREATE, - scopes: RouteResourceScope.getScopes({ - name, - types: 'read', - additionalScopes: scopes - }), - scopesSecurityEnabled, + scopes: ModelScopes.getScopes(resource, ['create'], scopes), + enableScopes, method: 'post', path: `/${name}`, - action: resourceAction(options, resourceCreate), + action: baseAction(options, resourceCreate), validator: options.createValidator, middlewares: options.middlewares, security: options.security diff --git a/src/core/domains/express/routing/RouteResourceScope.ts b/src/core/domains/express/routing/RouteResourceScope.ts index 65d16d761..471f79915 100644 --- a/src/core/domains/express/routing/RouteResourceScope.ts +++ b/src/core/domains/express/routing/RouteResourceScope.ts @@ -1,45 +1,48 @@ -export type RouteResourceScopeType = 'read' | 'write' | 'delete' | 'all'; +import { Scope } from "@src/core/domains/auth/interfaces/IScope"; +import ModelScopes from "@src/core/domains/auth/services/ModelScopes"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; -export const defaultRouteResourceScopes: RouteResourceScopeType[] = ['read', 'write', 'delete', 'all']; + +export const defaultRouteResourceScopes: Scope[] = ['all']; export type GetScopesOptions = { - name: string, - types?: RouteResourceScopeType[] | RouteResourceScopeType, - additionalScopes?: string[] + resource: ModelConstructor, + scopes?: Scope[] | Scope } class RouteResourceScope { /** * Generates a list of scopes, given a resource name and some scope types. - * @param name The name of the resource + * @param resource The model as a constructor * @param types The scope type(s) to generate scopes for. If a string, it will be an array of only that type. * @param additionalScopes Additional scopes to append to the output * @returns A list of scopes in the format of 'resourceName:scopeType' * * Example: * - * const scopes = RouteResourceScope.getScopes('blog', ['write', 'all'], ['otherScope']) + * const scopes = RouteResourceScope.getScopes(BlogModel, ['write', 'read'], ['otherScope']) * * // Output * [ - * 'blog:write', - * 'blog:all', + * 'BlogModel:write', + * 'BlogModel:read', * 'otherScope' * ] */ public static getScopes(options: GetScopesOptions): string[] { - const { - name, - types = defaultRouteResourceScopes, - additionalScopes = [] - } = options + const resource = options.resource + let scopes = options.scopes ?? ModelScopes.getScopes(resource, ['all']); - const typesArray = typeof types === 'string' ? [types] : types; + // Shape the scopes to an array + scopes = typeof scopes === 'string' ? [scopes] : scopes; + // Generate scopes from the resource + const resourceScopes = ModelScopes.getScopes(resource, scopes as Scope[]); + return [ - ...typesArray.map(type => `${name}:${type}`), - ...additionalScopes + ...defaultRouteResourceScopes, + ...resourceScopes ]; } diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 6cf798f04..34f686fa3 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -147,7 +147,7 @@ export default class ExpressService extends Service implements I } /** - * Adds security middleware to the route. If the route has scopesSecurityEnabled + * Adds security middleware to the route. If the route has enableScopes * and scopes is present, it adds the HAS_SCOPE security rule to the route. * Then it adds the security middleware to the route's middleware array. * @param route The route to add the middleware to @@ -159,7 +159,7 @@ export default class ExpressService extends Service implements I /** * Check if scopes is present, add related security rule */ - if (route?.scopesSecurityEnabled && route?.scopes?.length) { + if (route?.enableScopes && route?.scopes?.length) { route.security = [ ...(route.security ?? []), SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes) @@ -211,7 +211,7 @@ export default class ExpressService extends Service implements I if (route.scopes?.length) { str += ` with scopes: [${route.scopes.join(', ')}]` - if (route?.scopesSecurityEnabled) { + if (route?.enableScopes) { str += ' (scopes security ON)' } else { diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index a449d32e6..117b4b827 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -97,6 +97,17 @@ class Security extends Singleton { return SecurityRules[SecurityIdentifiers.RESOURCE_OWNER](attribute); } + /** + * Enable scope security checks. + * + * This will include scope security checks for all route resources. + * + * @returns A security callback that can be used in the security definition. + */ + public static enableScopes(): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.ENABLE_SCOPES](); + } + /** * Checks if the request is authorized, i.e. if the user is logged in. * diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index 47fb5627d..7e38f4058 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -96,7 +96,8 @@ class SecurityReader { // We need to find the unrelated security rule that has the ID in 'also' const unrelatedSecurityRule = security?.find(security => { - return security.also === id && + const also = typeof security.also === 'string' ? [security.also] : security.also; + return also?.includes(id) && conditionNeverPassable(when, security.never) === false && conditionPassable(security.when); }); diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index fc76b0460..a01f64bf5 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -26,6 +26,7 @@ export const SecurityIdentifiers = { HAS_ROLE: 'hasRole', HAS_SCOPE: 'hasScope', RATE_LIMITED: 'rateLimited', + ENABLE_SCOPES: 'enableScopes', CUSTOM: 'custom' } as const; @@ -75,6 +76,18 @@ const SecurityRules: ISecurityRules = { callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) }), + /** + * Enable scopes on the resource + * @returns + */ + [SecurityIdentifiers.ENABLE_SCOPES]: () => ({ + id: SecurityIdentifiers.ENABLE_SCOPES, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + // eslint-disable-next-line no-unused-vars + callback: (_req: BaseRequest, _resource: IModel) => true, + }), + /** * Checks if the currently logged in user has the given role. * @param roles diff --git a/src/core/domains/make/templates/Observer.ts.template b/src/core/domains/make/templates/Observer.ts.template index 281693c9c..19166703d 100644 --- a/src/core/domains/make/templates/Observer.ts.template +++ b/src/core/domains/make/templates/Observer.ts.template @@ -11,7 +11,7 @@ export default class #name# extends Observer { * @param data The model data being created. * @returns The processed model data. */ - created(data: I#name#Data): I#name#Data { + async created(data: I#name#Data): Promise { return data } @@ -20,7 +20,7 @@ export default class #name# extends Observer { * @param data The model data being created. * @returns The processed model data. */ - creating(data: I#name#Data): I#name#Data { + async creating(data: I#name#Data): Promise { return data } @@ -29,7 +29,7 @@ export default class #name# extends Observer { * @param data The model data being updated. * @returns The processed model data. */ - updating(data: I#name#Data): I#name#Data { + async updating(data: I#name#Data): Promise { return data } @@ -38,7 +38,7 @@ export default class #name# extends Observer { * @param data The model data that has been updated. * @returns The processed model data. */ - updated(data: I#name#Data): I#name#Data { + async updated(data: I#name#Data): Promise { return data } @@ -47,7 +47,7 @@ export default class #name# extends Observer { * @param data The model data being deleted. * @returns The processed model data. */ - deleting(data: I#name#Data): I#name#Data { + async deleting(data: I#name#Data): Promise { return data } @@ -56,7 +56,7 @@ export default class #name# extends Observer { * @param data The model data that has been deleted. * @returns The processed model data. */ - deleted(data: I#name#Data): I#name#Data { + async deleted(data: I#name#Data): Promise { return data } diff --git a/src/core/interfaces/IModel.ts b/src/core/interfaces/IModel.ts index 671370270..a6370c3ee 100644 --- a/src/core/interfaces/IModel.ts +++ b/src/core/interfaces/IModel.ts @@ -12,7 +12,6 @@ export type ModelConstructor = new (...args: any[]) = export type ModelInstance> = InstanceType - /** * @interface IModel * @description Abstract base class for database models. From 5dd4154800b8e2e3a123c67d06532828ef0b85a0 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 16:43:45 +0100 Subject: [PATCH 28/76] Updated comment --- src/core/domains/auth/services/ModelScopes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/domains/auth/services/ModelScopes.ts b/src/core/domains/auth/services/ModelScopes.ts index 9a28c16dc..22686c4d4 100644 --- a/src/core/domains/auth/services/ModelScopes.ts +++ b/src/core/domains/auth/services/ModelScopes.ts @@ -10,7 +10,6 @@ class ModelScopes { * - all - All scopes * - read - Read scopes * - write - Write scopes - * - update - Update scopes * - delete - Delete scopes * - create - Create scopes * From 8eaaddc6cb3b80af16210049d5266271c2c72449 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 22:01:27 +0100 Subject: [PATCH 29/76] Added alsoArguments option, perform enableScopes check in ExpressService to apply over all routes --- .../domains/express/interfaces/ISecurity.ts | 22 +++++++++++++------ .../domains/express/routing/RouteResource.ts | 9 +------- .../express/services/ExpressService.ts | 12 ++++++++++ .../express/services/SecurityReader.ts | 5 ++++- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index 1eac4924c..bd564113d 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -17,20 +17,28 @@ export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; /** * An interface for defining security callbacks with an identifier. + * + * id - The identifier for the security callback. + * also - The security rule to include in the callback. + * alsoArguments - The arguments for the security rule to include in the callback. + * Example: + * alsoArguments: { + * [SecurityIdentifiers.CUSTOM]: { + * paramName: 'value', + * paramName2: 'value2', + * }, + * } + * when - The condition for when the security check should be executed. Defaults to 'always'. + * never - The condition for when the security check should never be executed. + * callback - The security callback function. */ export type IIdentifiableSecurityCallback = { - // The identifier for the security callback. id: string; - // Include another security rule in the callback. - // TODO: We could add another type here 'alsoArguments' if extra parameters are required also?: string[] | string | null; - // The condition for when the security check should be executed. Defaults to 'always'. + alsoArguments?: Record; when: string[] | null; - // The condition for when the security check should never be executed. never: string[] | null; - // The arguments for the security callback. arguements?: Record; - // The security callback function. callback: SecurityCallback; } diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 9ce819d4c..857536dc9 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -9,7 +9,6 @@ import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; -import { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; /** @@ -46,15 +45,9 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const { resource, scopes = [], - enableScopes: enableScopesOption + enableScopes, } = options; - /** - * Check if scopes are enabled - */ - const hasEnableScopesSecurity = options.security?.find(security => security.id === SecurityIdentifiers.ENABLE_SCOPES); - const enableScopes = enableScopesOption ?? typeof hasEnableScopesSecurity !== 'undefined'; - /** * Define all the routes for the resource */ diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 34f686fa3..7eb655226 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -156,6 +156,18 @@ export default class ExpressService extends Service implements I public addSecurityMiddleware(route: IRoute): Middleware[] { const middlewares: Middleware[] = []; + /** + * Enabling Scopes Security + * - If enableScopes has not been defined in the route, check if it has been defined in the security rules + * - If yes, set enableScopes to true + */ + const hasEnableScopesSecurity = route.security?.find(security => security.id === SecurityIdentifiers.ENABLE_SCOPES); + const enableScopes = route.enableScopes ?? typeof hasEnableScopesSecurity !== 'undefined'; + + if(enableScopes) { + route.enableScopes = true + } + /** * Check if scopes is present, add related security rule */ diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index 7e38f4058..cb9394c1e 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -105,7 +105,10 @@ class SecurityReader { // The 'unrelatedSecurityRule' contains the 'also' property. // We can use it to fetch the desired security rule. if(unrelatedSecurityRule) { - result = SecurityRules[unrelatedSecurityRule.also as string]() + const alsoArguments = unrelatedSecurityRule.alsoArguments ?? {}; + const alsoSecurity = SecurityRules[unrelatedSecurityRule.also as string](Object.values(alsoArguments)); + + return alsoSecurity; } } From 86bf9d57bf6664710423f3cfb2fa11f9d2be34c4 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 23:00:40 +0100 Subject: [PATCH 30/76] Updated RouteResource to allow for partial scopes (fixes scope all) --- src/app/models/auth/ApiToken.ts | 12 ++-- src/config/auth.ts | 2 +- .../domains/auth/interfaces/IApitokenModel.ts | 2 +- src/core/domains/auth/services/Scopes.ts | 62 ++++++++++++++++--- src/core/domains/express/interfaces/IRoute.ts | 1 + .../domains/express/routing/RouteResource.ts | 15 +++-- .../domains/express/rules/hasScopeSecurity.ts | 14 ++++- .../express/services/ExpressService.ts | 24 ++++--- .../domains/express/services/SecurityRules.ts | 30 ++++----- 9 files changed, 117 insertions(+), 45 deletions(-) diff --git a/src/app/models/auth/ApiToken.ts b/src/app/models/auth/ApiToken.ts index e4ad8034f..bc65f3a1d 100644 --- a/src/app/models/auth/ApiToken.ts +++ b/src/app/models/auth/ApiToken.ts @@ -3,6 +3,7 @@ import ApiTokenObserver from '@src/app/observers/ApiTokenObserver'; import Model from '@src/core/base/Model'; import IApiTokenModel, { IApiTokenData } from '@src/core/domains/auth/interfaces/IApitokenModel'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; +import Scopes from '@src/core/domains/auth/services/Scopes'; /** * ApiToken model @@ -62,15 +63,14 @@ class ApiToken extends Model implements IApiTokenModel { * @param scopes The scope(s) to check * @returns True if all scopes are present, false otherwise */ - public hasScope(scopes: string | string[]): boolean { + public hasScope(scopes: string | string[], exactMatch: boolean = true): boolean { const currentScopes = this.getAttribute('scopes') ?? []; - scopes = typeof scopes === 'string' ? [scopes] : scopes; - - for(const scope of scopes) { - if(!currentScopes.includes(scope)) return false; + + if(exactMatch) { + return Scopes.exactMatch(currentScopes, scopes); } - return true; + return Scopes.partialMatch(currentScopes, scopes); } } diff --git a/src/config/auth.ts b/src/config/auth.ts index 4db9920e9..fab6c3fd8 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -99,7 +99,7 @@ const config: IAuthConfig = { }, { name: GROUPS.Admin, - roles: [ROLES.ADMIN], + roles: [ROLES.USER, ROLES.ADMIN], scopes: [] } ] diff --git a/src/core/domains/auth/interfaces/IApitokenModel.ts b/src/core/domains/auth/interfaces/IApitokenModel.ts index 225e87371..0921c3461 100644 --- a/src/core/domains/auth/interfaces/IApitokenModel.ts +++ b/src/core/domains/auth/interfaces/IApitokenModel.ts @@ -11,5 +11,5 @@ export interface IApiTokenData extends IModelData { export default interface IApiTokenModel extends IModel { user(): Promise; - hasScope(scopes: string | string[]): boolean; + hasScope(scopes: string | string[], exactMatch?: boolean): boolean; } \ No newline at end of file diff --git a/src/core/domains/auth/services/Scopes.ts b/src/core/domains/auth/services/Scopes.ts index 99df79ad1..d6d724230 100644 --- a/src/core/domains/auth/services/Scopes.ts +++ b/src/core/domains/auth/services/Scopes.ts @@ -1,9 +1,57 @@ -const Scopes = { - READ: 'read', - WRITE: 'write', - DELETE: 'delete', - CREATE: 'create', - ALL: 'all' -} as const; +class Scopes { + /** + * Returns an object of default scopes that can be used in the system. + * @returns An object with the following properties: + * - READ: 'read' + * - WRITE: 'write' + * - DELETE: 'delete' + * - CREATE: 'create' + * - ALL: 'all' + */ + public static getDefaultScopes() { + return { + READ: 'read', + WRITE: 'write', + DELETE: 'delete', + CREATE: 'create', + ALL: 'all' + } as const; + } + + /** + * Checks if the given scopes match exactly with the scopes in the scopesMatch array. + * @param scopesMatch The array of scopes to check against + * @param scopesSearch The scopes to search for in the scopesMatch array + * @returns True if all scopes in scopesSearch are present in scopesMatch, false otherwise + */ + public static exactMatch(scopesMatch: string[] | string, scopesSearch: string[] | string): boolean { + scopesMatch = typeof scopesMatch === 'string' ? [scopesMatch] : scopesMatch; + scopesSearch = typeof scopesSearch === 'string' ? [scopesSearch] : scopesSearch; + + for(const scopeSearch of scopesSearch) { + if(!scopesMatch.includes(scopeSearch)) return false; + } + + return true; + } + + /** + * Checks if any of the given scopes match with the scopes in the scopesMatch array. + * @param scopesMatch The array of scopes to check against + * @param scopesSearch The scopes to search for in the scopesMatch array + * @returns True if any scopes in scopesSearch are present in scopesMatch, false otherwise + */ + public static partialMatch(scopesMatch: string[] | string, scopesSearch: string[] | string): boolean { + scopesMatch = typeof scopesMatch === 'string' ? [scopesMatch] : scopesMatch; + scopesSearch = typeof scopesSearch === 'string' ? [scopesSearch] : scopesSearch; + + for(const scopeSearch of scopesSearch) { + if(scopesMatch.includes(scopeSearch)) return true; + } + + return false; + } + +} export default Scopes \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 0763031a7..401285b7b 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -10,6 +10,7 @@ export interface IRoute { action: IRouteAction; resourceType?: string; scopes?: string[]; + scopesPartial?: string[]; enableScopes?: boolean; middlewares?: Middleware[]; validator?: ValidatorCtor; diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 857536dc9..a4b0a7771 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -56,7 +56,8 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.index`, resourceType: RouteResourceTypes.ALL, - scopes: ModelScopes.getScopes(resource, ['read'], scopes), + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), enableScopes, method: 'get', path: `/${name}`, @@ -68,7 +69,8 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.show`, resourceType: RouteResourceTypes.SHOW, - scopes: ModelScopes.getScopes(resource, ['read'], scopes), + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), enableScopes, method: 'get', path: `/${name}/:id`, @@ -80,7 +82,8 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.update`, resourceType: RouteResourceTypes.UPDATE, - scopes: ModelScopes.getScopes(resource, ['write'], scopes), + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['write', 'all']), enableScopes, method: 'put', path: `/${name}/:id`, @@ -93,7 +96,8 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.destroy`, resourceType: RouteResourceTypes.DESTROY, - scopes: ModelScopes.getScopes(resource, ['delete'], scopes), + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['delete', 'all']), enableScopes, method: 'delete', path: `/${name}/:id`, @@ -105,7 +109,8 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.create`, resourceType: RouteResourceTypes.CREATE, - scopes: ModelScopes.getScopes(resource, ['create'], scopes), + scopes, + scopesPartial: ModelScopes.getScopes(resource, ['create', 'all']), enableScopes, method: 'post', path: `/${name}`, diff --git a/src/core/domains/express/rules/hasScopeSecurity.ts b/src/core/domains/express/rules/hasScopeSecurity.ts index 548ec9d8e..d366cdca5 100644 --- a/src/core/domains/express/rules/hasScopeSecurity.ts +++ b/src/core/domains/express/rules/hasScopeSecurity.ts @@ -4,17 +4,25 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * Checks if the given scope(s) are present in the scopes of the current request's API token. * If no API token is found, it will return false. * @param req The request object - * @param scope The scope(s) to check + * @param scopesExactMatch The scope(s) to check * @returns True if all scopes are present, false otherwise */ -const hasScopeSecurity = (req: BaseRequest, scope: string | string[]): boolean => { +const hasScopeSecurity = (req: BaseRequest, scopesExactMatch: string | string[], scopesPartialMatch: string | string[]): boolean => { const apiToken = req.apiToken; if(!apiToken) { return false; } - return apiToken?.hasScope(scope) + if(scopesPartialMatch.length && !apiToken.hasScope(scopesPartialMatch, false)) { + return false + } + + if(scopesExactMatch.length && !apiToken.hasScope(scopesExactMatch, true)) { + return false + } + + return true; } export default hasScopeSecurity \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 7eb655226..65c45bed7 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -164,17 +164,17 @@ export default class ExpressService extends Service implements I const hasEnableScopesSecurity = route.security?.find(security => security.id === SecurityIdentifiers.ENABLE_SCOPES); const enableScopes = route.enableScopes ?? typeof hasEnableScopesSecurity !== 'undefined'; - if(enableScopes) { + if (enableScopes) { route.enableScopes = true } - + /** * Check if scopes is present, add related security rule */ - if (route?.enableScopes && route?.scopes?.length) { + if (route?.enableScopes && (route?.scopes?.length || route?.scopesPartial?.length)) { route.security = [ ...(route.security ?? []), - SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes) + SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes, route.scopesPartial) ] } @@ -220,14 +220,22 @@ export default class ExpressService extends Service implements I private logRoute(route: IRoute): void { let str = `[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`; - if (route.scopes?.length) { - str += ` with scopes: [${route.scopes.join(', ')}]` + if (route.scopes?.length || route.scopesPartial?.length) { + str += "\r\n SECURITY: "; + + if (route.scopes?.length) { + str += ` with exact scopes: [${(route.scopes ?? []).join(', ')}]` + } + + if (route.scopesPartial?.length) { + str += ` with partial scopes: [${route.scopesPartial.join(', ')}]` + } if (route?.enableScopes) { - str += ' (scopes security ON)' + str += ' (scopes enabled)' } else { - str += ' (scopes security OFF)' + str += ' (scopes disabled)' } } diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts index a01f64bf5..1a6f509f1 100644 --- a/src/core/domains/express/services/SecurityRules.ts +++ b/src/core/domains/express/services/SecurityRules.ts @@ -76,17 +76,6 @@ const SecurityRules: ISecurityRules = { callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) }), - /** - * Enable scopes on the resource - * @returns - */ - [SecurityIdentifiers.ENABLE_SCOPES]: () => ({ - id: SecurityIdentifiers.ENABLE_SCOPES, - when: Security.getInstance().getWhenAndReset(), - never: Security.getInstance().getNeverAndReset(), - // eslint-disable-next-line no-unused-vars - callback: (_req: BaseRequest, _resource: IModel) => true, - }), /** * Checks if the currently logged in user has the given role. @@ -101,17 +90,30 @@ const SecurityRules: ISecurityRules = { callback: (req: BaseRequest) => hasRoleSecurity(req, roles) }), + /** + * Enable scopes on the resource + * @returns + */ + [SecurityIdentifiers.ENABLE_SCOPES]: () => ({ + id: SecurityIdentifiers.ENABLE_SCOPES, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + // eslint-disable-next-line no-unused-vars + callback: (_req: BaseRequest, _resource: IModel) => true, + }), + /** * Checks if the currently logged in user has the given scope(s). - * @param scopes + * @param scopesExactMatch * @returns */ - [SecurityIdentifiers.HAS_SCOPE]: (scopes: string | string[]) => ({ + [SecurityIdentifiers.HAS_SCOPE]: (scopesExactMatch: string | string[] = [], scopesPartialMatch: string | string[] = []) => ({ id: SecurityIdentifiers.HAS_SCOPE, also: SecurityIdentifiers.AUTHORIZED, + arguements: { scopesExactMatch, scopesPartialMatch }, when: Security.getInstance().getWhenAndReset(), never: Security.getInstance().getNeverAndReset(), - callback: (req: BaseRequest) => hasScopeSecurity(req, scopes) + callback: (req: BaseRequest) => hasScopeSecurity(req, scopesExactMatch, scopesPartialMatch) }), /** From c4b8df63abe8380aa0ca5ab9e4df8d03e90c65b5 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 23:02:28 +0100 Subject: [PATCH 31/76] Remove hasScope from Security (prefer define scopes on route instead) --- src/core/domains/express/services/Security.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index 117b4b827..191a7853b 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -149,15 +149,6 @@ class Security extends Singleton { return SecurityRules[SecurityIdentifiers.HAS_ROLE](roles); } - /** - * Checks if the currently logged in user has the given scope(s). - * @param scopes The scope(s) to check. - * @returns A callback function to be used in the security definition. - */ - public static hasScope(scopes: string | string[]): IIdentifiableSecurityCallback { - return SecurityRules[SecurityIdentifiers.HAS_SCOPE](scopes); - } - /** * Creates a security callback to check if the currently IP address has not exceeded a given rate limit. * From 1a7adc2dbd8fca968c4f647d6d94d5eaf391c22e Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 23:07:44 +0100 Subject: [PATCH 32/76] Revert IIdentifiableSecurityCallback to string only also, removed arguments option --- src/core/domains/express/interfaces/ISecurity.ts | 3 +-- src/core/domains/express/services/SecurityReader.ts | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index bd564113d..6df63c4b5 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -34,8 +34,7 @@ export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; */ export type IIdentifiableSecurityCallback = { id: string; - also?: string[] | string | null; - alsoArguments?: Record; + also?: string | null; when: string[] | null; never: string[] | null; arguements?: Record; diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index cb9394c1e..e53599cee 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -96,8 +96,7 @@ class SecurityReader { // We need to find the unrelated security rule that has the ID in 'also' const unrelatedSecurityRule = security?.find(security => { - const also = typeof security.also === 'string' ? [security.also] : security.also; - return also?.includes(id) && + return security.also === id && conditionNeverPassable(when, security.never) === false && conditionPassable(security.when); }); @@ -105,10 +104,7 @@ class SecurityReader { // The 'unrelatedSecurityRule' contains the 'also' property. // We can use it to fetch the desired security rule. if(unrelatedSecurityRule) { - const alsoArguments = unrelatedSecurityRule.alsoArguments ?? {}; - const alsoSecurity = SecurityRules[unrelatedSecurityRule.also as string](Object.values(alsoArguments)); - - return alsoSecurity; + return SecurityRules[unrelatedSecurityRule.also as string](); } } From 2df60753c13ed7db2a873ff0c98b8613854d3c3b Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 23:08:36 +0100 Subject: [PATCH 33/76] Updated comment --- src/core/domains/express/interfaces/ISecurity.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index 6df63c4b5..62fd46b45 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -20,14 +20,6 @@ export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; * * id - The identifier for the security callback. * also - The security rule to include in the callback. - * alsoArguments - The arguments for the security rule to include in the callback. - * Example: - * alsoArguments: { - * [SecurityIdentifiers.CUSTOM]: { - * paramName: 'value', - * paramName2: 'value2', - * }, - * } * when - The condition for when the security check should be executed. Defaults to 'always'. * never - The condition for when the security check should never be executed. * callback - The security callback function. From 677f2eafa28b0cf68ba4a04c08f20953f20c46ed Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Fri, 27 Sep 2024 23:10:05 +0100 Subject: [PATCH 34/76] Updated comment --- src/core/domains/express/rules/hasScopeSecurity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/domains/express/rules/hasScopeSecurity.ts b/src/core/domains/express/rules/hasScopeSecurity.ts index d366cdca5..0700b3e21 100644 --- a/src/core/domains/express/rules/hasScopeSecurity.ts +++ b/src/core/domains/express/rules/hasScopeSecurity.ts @@ -4,7 +4,8 @@ import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; * Checks if the given scope(s) are present in the scopes of the current request's API token. * If no API token is found, it will return false. * @param req The request object - * @param scopesExactMatch The scope(s) to check + * @param scopesExactMatch The scope(s) to check - must be an exact match - ignores empty scopes + * @param scopesPartialMatch The scope(s) to check - must be a partial match - ignores empty scopes * @returns True if all scopes are present, false otherwise */ const hasScopeSecurity = (req: BaseRequest, scopesExactMatch: string | string[], scopesPartialMatch: string | string[]): boolean => { From b44929c9513d8fbd6d8c2b13eed8245c504b3bed Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 28 Sep 2024 00:03:22 +0100 Subject: [PATCH 35/76] Fix potential headers already sent bug --- .../express/middleware/authorizeMiddleware.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/domains/express/middleware/authorizeMiddleware.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts index 804c2fb20..f6ce1609b 100644 --- a/src/core/domains/express/middleware/authorizeMiddleware.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -13,7 +13,8 @@ import { NextFunction, Response } from 'express'; * @param req The request object * @param res The response object */ -const validateScopes = async (scopes: string[], req: BaseRequest, res: Response) => { +// eslint-disable-next-line no-unused-vars +const validateScopes = (scopes: string[], req: BaseRequest, res: Response): void | null => { if(scopes.length === 0) { return; } @@ -21,12 +22,11 @@ const validateScopes = async (scopes: string[], req: BaseRequest, res: Response) const apiToken = req.apiToken; if(!apiToken) { - responseError(req, res, new UnauthorizedError(), 401); - return; + throw new UnauthorizedError(); } if(!apiToken.hasScope(scopes)) { - responseError(req, res, new ForbiddenResourceError('Required scopes missing from authorization'), 403); + throw new ForbiddenResourceError(); } } @@ -52,7 +52,7 @@ export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRe await AuthRequest.attemptAuthorizeRequest(req); // Validate the scopes if the authorization was successful - validateScopes(scopes, req, res); + validateScopes(scopes, req, res) next(); } @@ -62,6 +62,10 @@ export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRe return; } + if(error instanceof ForbiddenResourceError) { + responseError(req, res, error, 403) + } + if(error instanceof Error) { responseError(req, res, error) return; From c7737a1e0b71c02ae9264a2cdf10c3514d5fa03b Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 28 Sep 2024 13:48:09 +0100 Subject: [PATCH 36/76] Fixed missing properties on auth test --- src/tests/auth/auth.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index f192abc51..f9c65cef1 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -12,6 +12,8 @@ import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; +import TestConsoleProvider from '../providers/TestConsoleProvider'; + describe('attempt to run app with normal appConfig', () => { let testUser: User; @@ -25,6 +27,7 @@ describe('attempt to run app with normal appConfig', () => { await Kernel.boot({ ...testAppConfig, providers: [ + new TestConsoleProvider(), new DatabaseProvider(), new AuthProvider() ] @@ -37,6 +40,7 @@ describe('attempt to run app with normal appConfig', () => { email, hashedPassword, roles: [], + groups: [], firstName: 'Tony', lastName: 'Stark' }) From d69a582f97dcb17665019c3c2b7addd6a11be3ed Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 28 Sep 2024 22:45:58 +0100 Subject: [PATCH 37/76] feat(commands): Commands can be registered with configs in the same method --- .../console/interfaces/ICommandRegister.ts | 6 ++++-- .../domains/console/service/CommandRegister.ts | 16 +++++++++++++--- .../migrations/providers/MigrationProvider.ts | 5 ----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/core/domains/console/interfaces/ICommandRegister.ts b/src/core/domains/console/interfaces/ICommandRegister.ts index ffb45890a..d90506503 100644 --- a/src/core/domains/console/interfaces/ICommandRegister.ts +++ b/src/core/domains/console/interfaces/ICommandRegister.ts @@ -11,14 +11,16 @@ export interface ICommandRegister { /** * Registers a new command. * @param cmdCtor The command to register. + * @param config The configuration for the commands. */ - register: (cmdCtor: ICommandConstructor) => void; + register: (cmdCtor: ICommandConstructor, config?: object) => void; /** * Registers multiple commands. * @param cmds The commands to register. + * @param config The configuration for the commands. */ - registerAll: (cmds: Array) => void; + registerAll: (cmds: Array, config?: object) => void; /** * Adds configuration for commands. diff --git a/src/core/domains/console/service/CommandRegister.ts b/src/core/domains/console/service/CommandRegister.ts index caf68eb52..628188f3e 100644 --- a/src/core/domains/console/service/CommandRegister.ts +++ b/src/core/domains/console/service/CommandRegister.ts @@ -19,17 +19,23 @@ export default class CommandRegister extends Singleton implements ICommandRegist /** * Register multiple commands * @param cmds + * @param config The configuration for the commands. */ - registerAll(cmds: Array) { + registerAll(cmds: Array, config?: object) { cmds.forEach(cmdCtor => this.register(cmdCtor)) + + if(config) { + const signatures = cmds.map(cmdCtor => (new cmdCtor).signature); + this.addCommandConfig(signatures, config); + } } /** * Register a new command - * @param key * @param cmdCtor + * @param config The configuration for the commands. */ - register(cmdCtor: ICommandConstructor) { + register(cmdCtor: ICommandConstructor, config?: object) { const signature = (new cmdCtor).signature if(this.commands.has(signature)) { @@ -37,6 +43,10 @@ export default class CommandRegister extends Singleton implements ICommandRegist } this.commands.set(signature, cmdCtor); + + if(config) { + this.addCommandConfig([signature], config); + } } /** diff --git a/src/core/domains/migrations/providers/MigrationProvider.ts b/src/core/domains/migrations/providers/MigrationProvider.ts index 07259292c..f4a3eb449 100644 --- a/src/core/domains/migrations/providers/MigrationProvider.ts +++ b/src/core/domains/migrations/providers/MigrationProvider.ts @@ -26,11 +26,6 @@ class MigrationProvider extends BaseProvider { App.container('console').register().registerAll([ MigrateUpCommand, MigrateDownCommand - ]) - - App.container('console').register().addCommandConfig([ - (new MigrateUpCommand).signature, - (new MigrateDownCommand).signature ], this.config) } From e80d4fd8aa394ecbb054cc4225257ef50b4b0ae2 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 28 Sep 2024 23:09:42 +0100 Subject: [PATCH 38/76] feat(auth): Configurable when tokens expire --- .env.example | 1 + src/config/auth.ts | 5 +++++ src/core/domains/auth/interfaces/IAuthConfig.ts | 1 + src/core/domains/auth/services/AuthService.ts | 2 +- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 97fe015f3..00dfd3782 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_EVENT_DRIVER=sync APP_WORKER_DRIVER=queue JWT_SECRET= +JWT_EXPIRES_IN_MINUTES=60 DATABASE_DEFAULT_CONNECTION=default DATABASE_DEFAULT_PROVIDER= diff --git a/src/config/auth.ts b/src/config/auth.ts index fab6c3fd8..c32d3f93e 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -49,6 +49,11 @@ const config: IAuthConfig = { */ jwtSecret: process.env.JWT_SECRET as string ?? '', + /** + * JWT expiration time in minutes + */ + expiresInMinutes: process.env.JWT_EXPIRES_IN_MINUTES ? parseInt(process.env.JWT_EXPIRES_IN_MINUTES) : 60, + /** * Enable or disable auth routes */ diff --git a/src/core/domains/auth/interfaces/IAuthConfig.ts b/src/core/domains/auth/interfaces/IAuthConfig.ts index cef0f4f9d..a5c9b143d 100644 --- a/src/core/domains/auth/interfaces/IAuthConfig.ts +++ b/src/core/domains/auth/interfaces/IAuthConfig.ts @@ -26,6 +26,7 @@ export interface IAuthConfig { updateUser: IInterfaceCtor; }; jwtSecret: string, + expiresInMinutes: number; enableAuthRoutes: boolean; enableAuthRoutesAllowCreate: boolean; permissions: IPermissionsConfig; diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index b44b4c7f2..7a057115b 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -84,7 +84,7 @@ export default class AuthService extends Service implements IAuthSe throw new Error('Invalid token'); } const payload = JWTTokenFactory.create(apiToken.data?.userId?.toString(), apiToken.data?.token); - return createJwt(this.config.jwtSecret, payload, '1d'); + return createJwt(this.config.jwtSecret, payload, `${this.config.expiresInMinutes}m`); } /** From 8fe11ba2f5554523d34c2eca8451d77c102aed5d Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 00:42:54 +0100 Subject: [PATCH 39/76] refactor(express): Renamed name to path in IRouteResourceOptions --- .../interfaces/IRouteResourceOptions.ts | 2 +- .../domains/express/routing/RouteResource.ts | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 78db1df93..8b945894c 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -7,7 +7,7 @@ import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; export interface IRouteResourceOptions extends Pick { - name: string; + path: string; resource: ModelConstructor; except?: ResourceType[]; only?: ResourceType[]; diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index a4b0a7771..83fbb8088 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -40,7 +40,7 @@ export const RouteResourceTypes = { * @returns A group of routes that can be used to handle requests for the resource */ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { - const name = options.name.startsWith('/') ? options.name.slice(1) : options.name + const path = options.path.startsWith('/') ? options.path.slice(1) : options.path const { resource, @@ -54,39 +54,39 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const routes = RouteGroup([ // Get all resources Route({ - name: `${name}.index`, + name: `${path}.index`, resourceType: RouteResourceTypes.ALL, scopes, scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), enableScopes, method: 'get', - path: `/${name}`, + path: `/${path}`, action: baseAction(options, resourceIndex), middlewares: options.middlewares, security: options.security }), // Get resource by id Route({ - name: `${name}.show`, + name: `${path}.show`, resourceType: RouteResourceTypes.SHOW, scopes, scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), enableScopes, method: 'get', - path: `/${name}/:id`, + path: `/${path}/:id`, action: baseAction(options, resourceShow), middlewares: options.middlewares, security: options.security }), // Update resource by id Route({ - name: `${name}.update`, + name: `${path}.update`, resourceType: RouteResourceTypes.UPDATE, scopes, scopesPartial: ModelScopes.getScopes(resource, ['write', 'all']), enableScopes, method: 'put', - path: `/${name}/:id`, + path: `/${path}/:id`, action: baseAction(options, resourceUpdate), validator: options.updateValidator, middlewares: options.middlewares, @@ -94,26 +94,26 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { }), // Delete resource by id Route({ - name: `${name}.destroy`, + name: `${path}.destroy`, resourceType: RouteResourceTypes.DESTROY, scopes, scopesPartial: ModelScopes.getScopes(resource, ['delete', 'all']), enableScopes, method: 'delete', - path: `/${name}/:id`, + path: `/${path}/:id`, action: baseAction(options, resourceDelete), middlewares: options.middlewares, security: options.security }), // Create resource Route({ - name: `${name}.create`, + name: `${path}.create`, resourceType: RouteResourceTypes.CREATE, scopes, scopesPartial: ModelScopes.getScopes(resource, ['create', 'all']), enableScopes, method: 'post', - path: `/${name}`, + path: `/${path}`, action: baseAction(options, resourceCreate), validator: options.createValidator, middlewares: options.middlewares, From 51ea3a291b275d9202120d0341da2523dca57645 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 01:05:05 +0100 Subject: [PATCH 40/76] feat(express): Updated logRoute with security rules --- .../express/services/ExpressService.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 65c45bed7..332088b89 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -218,24 +218,37 @@ export default class ExpressService extends Service implements I * @param route - IRoute instance */ private logRoute(route: IRoute): void { + const indent = ' '; let str = `[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`; if (route.scopes?.length || route.scopesPartial?.length) { - str += "\r\n SECURITY: "; + str += `\r\n${indent}SECURITY:`; if (route.scopes?.length) { - str += ` with exact scopes: [${(route.scopes ?? []).join(', ')}]` + str += indent + `with exact scopes: [${(route.scopes ?? []).join(', ')}]` } if (route.scopesPartial?.length) { - str += ` with partial scopes: [${route.scopesPartial.join(', ')}]` + str += indent + `with partial scopes: [${route.scopesPartial.join(', ')}]` } if (route?.enableScopes) { - str += ' (scopes enabled)' + str += indent + '(scopes enabled)' } else { - str += ' (scopes disabled)' + str += indent + '(scopes disabled)' + } + } + + for(const security of (route?.security ?? [])) { + str += `\r\n${indent}SECURITY:${indent}${security.id}` + + if(Array.isArray(security.when)) { + str += indent + `with when: [${security.when.join(', ')}]` + } + + if(Array.isArray(security.never)) { + str += indent + `with never: [${security.never.join(', ')}]` } } From f4e990ec1659492c34d79eab84c7ae6358248b22 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 15:07:27 +0100 Subject: [PATCH 41/76] feat(express): Added index, all filters to RouteResources --- src/core/domains/express/actions/resourceIndex.ts | 9 +++++++-- src/core/domains/express/actions/resourceShow.ts | 10 ++++++++-- .../express/interfaces/IRouteResourceOptions.ts | 2 ++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 11863f864..3373aa604 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -42,6 +42,8 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp let results: IModel[] = []; + const filters = options.allFilters ?? {}; + /** * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user */ @@ -59,7 +61,10 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); } - results = await repository.findMany({ [propertyKey]: userId }) + results = await repository.findMany({ + ...filters, + [propertyKey]: userId + }) res.send(formatResults(results)) return; @@ -68,7 +73,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * Finds all results without any restrictions */ - results = await repository.findMany(); + results = await repository.findMany(filters); res.send(formatResults(results)) } diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 18c5aba89..454e764ef 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -33,6 +33,12 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp const repository = new Repository(options.resource); let result: IModel | null = null; + + // Define our query filters + const filters: object = { + ...(options.showFilters ?? {}), + id: req.params?.id + }; /** * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user @@ -52,7 +58,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp } result = await repository.findOne({ - id: req.params?.id, + ...filters, [propertyKey]: userId }) @@ -68,7 +74,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * Find resource without restrictions */ - result = await repository.findById(req.params?.id); + result = await repository.findOne(filters); if (!result) { throw new ModelNotFound('Resource not found'); diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 8b945894c..698cf040d 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -16,4 +16,6 @@ export interface IRouteResourceOptions extends Pick { security?: IIdentifiableSecurityCallback[]; scopes?: string[]; enableScopes?: boolean; + showFilters?: object; + allFilters?: object; } \ No newline at end of file From 06e230a82be79d1016b91e3c8a53040c31636fe8 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 23:12:34 +0100 Subject: [PATCH 42/76] refactor(make): Updated migration template --- .../domains/make/templates/Migration.ts.template | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/core/domains/make/templates/Migration.ts.template b/src/core/domains/make/templates/Migration.ts.template index 84a034f09..684043ec4 100644 --- a/src/core/domains/make/templates/Migration.ts.template +++ b/src/core/domains/make/templates/Migration.ts.template @@ -1,4 +1,5 @@ import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; +import { DataTypes } from "sequelize"; export class #name#Migration extends BaseMigration { @@ -12,14 +13,12 @@ export class #name#Migration extends BaseMigration * * @return {Promise} */ - async up(): Promise - { + async up(): Promise { // Example: // await this.schema.createTable('users', (table) => { - // table.increments('id').primary(); - // table.string('username').unique().notNullable(); - // table.string('email').unique().notNullable(); - // table.timestamps(true, true); + // userId: DataTypes.STRING, + // createdAt: DataTypes.DATE, + // updatedAt: DataTypes.DATE // }); } @@ -28,8 +27,7 @@ export class #name#Migration extends BaseMigration * * @return {Promise} */ - async down(): Promise - { + async down(): Promise { // Example: // await this.schema.dropTable('users'); } From 381072f2e74fd5d7534ca885f4afc0e39ef0f298 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 23:19:25 +0100 Subject: [PATCH 43/76] fix(models): Remove Model when generating default table name --- src/core/base/Model.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 4b8ff0c32..93af807d2 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -101,7 +101,14 @@ export default abstract class Model extends WithObserve if (this.table) { return; } - this.table = Str.plural(Str.startLowerCase(this.constructor.name)); + this.table = this.constructor.name; + + if(this.table.endsWith('Model')) { + this.table = this.table.slice(0, -5); + } + + this.table = Str.plural(Str.startLowerCase(this.table)) + } /** From b94862c742f938459a944940205a9739d425f8a3 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 23:21:16 +0100 Subject: [PATCH 44/76] refactor(make): Updated migration template --- src/core/domains/make/templates/Migration.ts.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/domains/make/templates/Migration.ts.template b/src/core/domains/make/templates/Migration.ts.template index 684043ec4..59f1fd8ad 100644 --- a/src/core/domains/make/templates/Migration.ts.template +++ b/src/core/domains/make/templates/Migration.ts.template @@ -1,8 +1,8 @@ import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -export class #name#Migration extends BaseMigration -{ +export class #name#Migration extends BaseMigration { + // Specify the database provider if this migration should run on a particular database. // Uncomment and set to 'mongodb', 'postgres', or another supported provider. // If left commented out, the migration will run only on the default provider. From bb5cd6cf8eda950593ff0ac7a5354f45e4199fcc Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 23:21:54 +0100 Subject: [PATCH 45/76] refactor(make): Updated migration table --- src/core/domains/make/templates/Migration.ts.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/domains/make/templates/Migration.ts.template b/src/core/domains/make/templates/Migration.ts.template index 59f1fd8ad..24a13ddf7 100644 --- a/src/core/domains/make/templates/Migration.ts.template +++ b/src/core/domains/make/templates/Migration.ts.template @@ -15,7 +15,7 @@ export class #name#Migration extends BaseMigration { */ async up(): Promise { // Example: - // await this.schema.createTable('users', (table) => { + // await this.schema.createTable('users', { // userId: DataTypes.STRING, // createdAt: DataTypes.DATE, // updatedAt: DataTypes.DATE From e71ae0f36898c1e0b7bf67e2ca018ed7a2b9fc39 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 29 Sep 2024 23:49:31 +0100 Subject: [PATCH 46/76] feat(database): Added limit, skip functionality --- .../database/builder/PostgresQueryBuilder.ts | 13 +++++++++++-- .../documentManagers/MongoDbDocumentManager.ts | 4 +++- .../domains/database/interfaces/IDocumentManager.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/core/domains/database/builder/PostgresQueryBuilder.ts b/src/core/domains/database/builder/PostgresQueryBuilder.ts index 1f44a4268..0e94f125f 100644 --- a/src/core/domains/database/builder/PostgresQueryBuilder.ts +++ b/src/core/domains/database/builder/PostgresQueryBuilder.ts @@ -32,6 +32,11 @@ export type SelectOptions = { * Limit */ limit?: number + + /** + * Skip + */ + skip?: number } /** @@ -44,7 +49,7 @@ class PostgresQueryBuilder { * @param options Select options * @returns Query string */ - select({ fields, tableName, filter = {}, order = [], limit = undefined }: SelectOptions): string { + select({ fields, tableName, filter = {}, order = [], limit = undefined, skip = undefined }: SelectOptions): string { let queryStr = `SELECT ${this.selectColumnsClause(fields)} FROM "${tableName}"`; if(Object.keys(filter ?? {}).length > 0) { @@ -55,10 +60,14 @@ class PostgresQueryBuilder { queryStr += ` ORDER BY ${this.orderByClause(order)}` } - if(limit) { + if(limit && !skip) { queryStr += ` LIMIT ${limit}` } + if(skip && limit) { + queryStr += ` OFFSET ${skip} LIMIT ${limit}` + } + return queryStr; } diff --git a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts index 022df75a6..d7f4ff47f 100644 --- a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts +++ b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts @@ -120,13 +120,15 @@ class MongoDbDocumentManager extends BaseDocumentManager({ filter, order }: FindOptions): Promise { + async findMany({ filter, order, limit, skip }: FindOptions): Promise { return this.captureError(async() => { const documents = await this.driver .getDb() .collection(this.getTable()) .find(filter as object, { sort: order ? this.convertOrderToSort(order ?? []) : undefined, + limit, + skip }) .toArray(); diff --git a/src/core/domains/database/interfaces/IDocumentManager.ts b/src/core/domains/database/interfaces/IDocumentManager.ts index 55eef796e..4b4c5a01b 100644 --- a/src/core/domains/database/interfaces/IDocumentManager.ts +++ b/src/core/domains/database/interfaces/IDocumentManager.ts @@ -10,7 +10,7 @@ export interface IDatabaseDocument { } export type OrderOptions = Record[]; -export type FindOptions = { filter?: object, order?: OrderOptions } +export type FindOptions = { filter?: object, order?: OrderOptions, limit?: number, skip?: number }; /** * Provides methods for interacting with a database table. From 9995bf4f39a22f354e2658852411fa93cd86c837 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 30 Sep 2024 00:09:02 +0100 Subject: [PATCH 47/76] feat(express): Added pagination to route resource --- .../domains/express/actions/resourceIndex.ts | 34 +++++++++---- .../interfaces/IRouteResourceOptions.ts | 5 +- src/core/domains/express/services/Paginate.ts | 48 +++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 src/core/domains/express/services/Paginate.ts diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 3373aa604..4a8223dd0 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,8 +1,9 @@ -import Repository from '@src/core/base/Repository'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; +import Paginate from '@src/core/domains/express/services/Paginate'; import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; @@ -30,6 +31,11 @@ const formatResults = (results: IModel[]) => results.map(result => r */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const paginate = new Paginate().parseRequest(req); + const page = paginate.getPage(1); + const pageSize = paginate.getPageSize() ?? options?.paginate?.pageSize; + const skip = pageSize ? (page - 1) * pageSize : undefined; + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); @@ -37,8 +43,10 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp responseError(req, res, new UnauthorizedError(), 401) return; } - - const repository = new Repository(options.resource); + + const tableName = (new options.resource(null)).table; + + const documentManager = App.container('db').documentManager().table(tableName) as IDocumentManager; let results: IModel[] = []; @@ -61,9 +69,13 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); } - results = await repository.findMany({ - ...filters, - [propertyKey]: userId + results = await documentManager.findMany({ + filter: { + ...filters, + [propertyKey]: userId + }, + limit: pageSize, + skip, }) res.send(formatResults(results)) @@ -73,9 +85,15 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * Finds all results without any restrictions */ - results = await repository.findMany(filters); + results = await documentManager.findMany({ + filter: filters, + limit: pageSize, + skip, + }) + + const resultsAsModels = results.map((result) => new options.resource(result)); - res.send(formatResults(results)) + res.send(formatResults(resultsAsModels)) } catch (err) { if (err instanceof Error) { diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 698cf040d..5ec225486 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -4,7 +4,7 @@ import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; -export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; +export type ResourceType = 'all' | 'create' | 'update' | 'show' | 'destroy'; export interface IRouteResourceOptions extends Pick { path: string; @@ -18,4 +18,7 @@ export interface IRouteResourceOptions extends Pick { enableScopes?: boolean; showFilters?: object; allFilters?: object; + paginate?: { + pageSize: number; + } } \ No newline at end of file diff --git a/src/core/domains/express/services/Paginate.ts b/src/core/domains/express/services/Paginate.ts new file mode 100644 index 000000000..474183bc3 --- /dev/null +++ b/src/core/domains/express/services/Paginate.ts @@ -0,0 +1,48 @@ +import Singleton from "@src/core/base/Singleton"; +import { Request } from "express"; + +class Paginate extends Singleton { + + protected page: number | undefined = undefined + + protected pageSize: number | undefined = undefined; + + /** + * Parses the request object to extract the page and pageSize from the query string + * + * @param {Request} req - The Express Request object + * @returns {this} - The Paginate class itself to enable chaining + */ + parseRequest(req: Request): this { + if(req.query?.page) { + this.page = parseInt(req.query?.page as string); + } + + if(req.query?.pageSize) { + this.pageSize = parseInt(req.query?.pageSize as string); + } + + return this + } + + /** + * Gets the page number, defaulting to 1 if undefined. + * @param {number} defaultValue - The default value if this.page is undefined. + * @returns {number} - The page number. + */ + getPage(defaultValue: number = 1): number { + return this.page ?? defaultValue + } + + /** + * Gets the page size, defaulting to the defaultValue if undefined. + * @param {number} [defaultValue=undefined] - The default value if this.pageSize is undefined. + * @returns {number | undefined} - The page size. + */ + getPageSize(defaultValue?: number): number | undefined { + return this.pageSize ?? defaultValue + } + +} + +export default Paginate \ No newline at end of file From 535ff5537c88a2ab7eec10cc499b8012d5dddad4 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 30 Sep 2024 00:09:33 +0100 Subject: [PATCH 48/76] refactor(test): Fix import on auth.test.ts --- src/tests/auth/auth.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index f9c65cef1..4b7599b88 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -11,8 +11,7 @@ import DatabaseProvider from '@src/core/domains/database/providers/DatabaseProvi import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; - -import TestConsoleProvider from '../providers/TestConsoleProvider'; +import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; describe('attempt to run app with normal appConfig', () => { From 29f4ff324bf633f240d447faa5b4a02dfdae8f61 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Wed, 2 Oct 2024 23:13:23 +0100 Subject: [PATCH 49/76] docs(changelist): Added changelist.md and bumped version to 1.1.0 --- changelist.md | 43 +++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 changelist.md diff --git a/changelist.md b/changelist.md new file mode 100644 index 000000000..628410e8a --- /dev/null +++ b/changelist.md @@ -0,0 +1,43 @@ +## Version 1.0.1 (Beta) + +### Security Enhancements +- Added security features to Express routes +- Implemented rate limiting +- Added configurable token expiration +- Introduced user scopes, resource scopes, and API token scopes +- Implemented permission groups, user groups, and roles + +### Authentication and Authorization +- Refactored security rules and middleware +- Updated authorization middleware to include scopes +- Improved handling of custom identifiers + +### Request Handling +- Refactored CurrentRequest into RequestContext +- Added IP address handling to RequestContext +- Moved RequestContext into an app container + +### Route Resources +- Added 'index' and 'all' filters to RouteResources +- Renamed 'name' to 'path' in IRouteResourceOptions +- Updated to allow for partial scopes + +### Command Handling +- Fixed argument processing in ListRoutesCommand +- Enabled registration of commands with configs in the same module + +### Code Refactoring and Optimization +- Consolidated security interfaces into a single file +- Removed debug console logs +- Fixed incorrect import paths +- Refactored Express domain files + +### Bug Fixes +- Resolved a potential "headers already sent" issue +- Fixed migration failures related to missing files +- Corrected custom identifier handling + +### Miscellaneous +- Updated Observer with awaitable methods +- Improved route logging to include security rules +- Various comment updates for improved clarity \ No newline at end of file diff --git a/package.json b/package.json index c56eacf16..37192da01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "larascript-framework", - "version": "1.0.1", + "version": "1.1.1", "description": "A Node.js framework inspired by Laravel made with TypeScript", "main": "index.js", "scripts": { From 728e2e562a7c6e90e82f2b0c5078c2c6fff4876c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Thu, 3 Oct 2024 22:59:55 +0100 Subject: [PATCH 50/76] feat(express): Added pageSizeAllowOverride option --- src/core/domains/express/actions/resourceIndex.ts | 2 +- .../domains/express/interfaces/IRouteResourceOptions.ts | 1 + src/core/domains/express/services/Paginate.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 4a8223dd0..811dc0246 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -31,7 +31,7 @@ const formatResults = (results: IModel[]) => results.map(result => r */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const paginate = new Paginate().parseRequest(req); + const paginate = new Paginate().parseRequest(req, options.paginate); const page = paginate.getPage(1); const pageSize = paginate.getPageSize() ?? options?.paginate?.pageSize; const skip = pageSize ? (page - 1) * pageSize : undefined; diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 5ec225486..5c6166273 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -20,5 +20,6 @@ export interface IRouteResourceOptions extends Pick { allFilters?: object; paginate?: { pageSize: number; + allowPageSizeOverride?: boolean; } } \ No newline at end of file diff --git a/src/core/domains/express/services/Paginate.ts b/src/core/domains/express/services/Paginate.ts index 474183bc3..8bf3ae7d4 100644 --- a/src/core/domains/express/services/Paginate.ts +++ b/src/core/domains/express/services/Paginate.ts @@ -1,6 +1,10 @@ import Singleton from "@src/core/base/Singleton"; import { Request } from "express"; +export type ParseRequestOptions = { + allowPageSizeOverride?: boolean +} + class Paginate extends Singleton { protected page: number | undefined = undefined @@ -13,12 +17,12 @@ class Paginate extends Singleton { * @param {Request} req - The Express Request object * @returns {this} - The Paginate class itself to enable chaining */ - parseRequest(req: Request): this { + parseRequest(req: Request, options: ParseRequestOptions = { allowPageSizeOverride: true }): this { if(req.query?.page) { this.page = parseInt(req.query?.page as string); } - if(req.query?.pageSize) { + if(options.allowPageSizeOverride && req.query?.pageSize) { this.pageSize = parseInt(req.query?.pageSize as string); } From f5e5df9cc05be5ed86490bd854734906ed10ec3c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 00:53:38 +0100 Subject: [PATCH 51/76] refactor(kernel): set app environment before loading providers --- src/core/Kernel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/Kernel.ts b/src/core/Kernel.ts index 1b17df422..f44bbf24c 100644 --- a/src/core/Kernel.ts +++ b/src/core/Kernel.ts @@ -43,6 +43,8 @@ export default class Kernel extends Singleton const { appConfig } = kernel; + App.getInstance().env = appConfig.environment; + for (const provider of appConfig.providers) { if(withoutProviders.includes(provider.constructor.name)) { continue; @@ -60,7 +62,7 @@ export default class Kernel extends Singleton kernel.preparedProviders.push(provider.constructor.name); } - App.getInstance().env = appConfig.environment; + Kernel.getInstance().readyProviders = [...kernel.preparedProviders]; } From 5cd080239d44e53e65aa884c1ffdf5d5eddc648f Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 12:27:44 +0100 Subject: [PATCH 52/76] refactor(express): Refactored resource index into service --- .../domains/express/actions/baseAction.ts | 8 +- .../domains/express/actions/resourceAll.ts | 30 ++++ .../domains/express/actions/resourceIndex.ts | 106 ------------- .../express/interfaces/IResourceService.ts | 15 ++ .../domains/express/routing/RouteResource.ts | 6 +- .../services/Resources/ResourceAllService.ts | 144 ++++++++++++++++++ .../utils/stripGuardedResourceProperties.ts | 6 + 7 files changed, 200 insertions(+), 115 deletions(-) create mode 100644 src/core/domains/express/actions/resourceAll.ts delete mode 100644 src/core/domains/express/actions/resourceIndex.ts create mode 100644 src/core/domains/express/interfaces/IResourceService.ts create mode 100644 src/core/domains/express/services/Resources/ResourceAllService.ts create mode 100644 src/core/domains/express/utils/stripGuardedResourceProperties.ts diff --git a/src/core/domains/express/actions/baseAction.ts b/src/core/domains/express/actions/baseAction.ts index c6e18f9dc..c63f463f2 100644 --- a/src/core/domains/express/actions/baseAction.ts +++ b/src/core/domains/express/actions/baseAction.ts @@ -11,10 +11,6 @@ import { Response } from 'express'; * @param {IAction} action The action function that will be called with the BaseRequest, Response, and options. * @return {(req: BaseRequest, res: Response) => Promise} A new action function that calls the given action with the given options. */ -const ResourceAction = (options: IRouteResourceOptions, action: IAction) => { - return (req: BaseRequest, res: Response) => { - return action(req, res, options) - } -} +const baseAction = (options: IRouteResourceOptions, action: IAction) => (req: BaseRequest, res: Response) => action(req, res, options) -export default ResourceAction \ No newline at end of file +export default baseAction \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceAll.ts b/src/core/domains/express/actions/resourceAll.ts new file mode 100644 index 000000000..a0b8a93c7 --- /dev/null +++ b/src/core/domains/express/actions/resourceAll.ts @@ -0,0 +1,30 @@ +import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { Response } from 'express'; + +import ResourceAllService from '../services/Resources/ResourceAllService'; + + +/** + * Finds all records in the resource's repository + * + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {IRouteResourceOptions} options - The options object + * @returns {Promise} + */ +export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { + try { + const resourceAllService = new ResourceAllService(); + resourceAllService.handler(req, res, options); + } + catch (err) { + if (err instanceof Error) { + responseError(req, res, err) + return; + } + + res.status(500).send({ error: 'Something went wrong' }) + } +} \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts deleted file mode 100644 index 811dc0246..000000000 --- a/src/core/domains/express/actions/resourceIndex.ts +++ /dev/null @@ -1,106 +0,0 @@ -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; -import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import Paginate from '@src/core/domains/express/services/Paginate'; -import { ALWAYS } from '@src/core/domains/express/services/Security'; -import SecurityReader from '@src/core/domains/express/services/SecurityReader'; -import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; -import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { IModel } from '@src/core/interfaces/IModel'; -import IModelData from '@src/core/interfaces/IModelData'; -import { App } from '@src/core/services/App'; -import { Response } from 'express'; - -/** - * Formats the results by excluding guarded properties - * - * @param results - * @returns - */ -const formatResults = (results: IModel[]) => results.map(result => result.getData({ excludeGuarded: true }) as IModel); - -/** - * Finds all records in the resource's repository - * - * @param {BaseRequest} req - The request object - * @param {Response} res - The response object - * @param {IRouteResourceOptions} options - The options object - * @returns {Promise} - */ -export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { - try { - const paginate = new Paginate().parseRequest(req, options.paginate); - const page = paginate.getPage(1); - const pageSize = paginate.getPageSize() ?? options?.paginate?.pageSize; - const skip = pageSize ? (page - 1) * pageSize : undefined; - - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - - const tableName = (new options.resource(null)).table; - - const documentManager = App.container('db').documentManager().table(tableName) as IDocumentManager; - - let results: IModel[] = []; - - const filters = options.allFilters ?? {}; - - /** - * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user - */ - if (resourceOwnerSecurity && authorizationSecurity) { - - const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('requestContext').getByRequest(req, 'userId'); - - if (!userId) { - responseError(req, res, new UnauthorizedError(), 401); - return; - } - - if (typeof propertyKey !== 'string') { - throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); - } - - results = await documentManager.findMany({ - filter: { - ...filters, - [propertyKey]: userId - }, - limit: pageSize, - skip, - }) - - res.send(formatResults(results)) - return; - } - - /** - * Finds all results without any restrictions - */ - results = await documentManager.findMany({ - filter: filters, - limit: pageSize, - skip, - }) - - const resultsAsModels = results.map((result) => new options.resource(result)); - - res.send(formatResults(resultsAsModels)) - } - catch (err) { - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) - } -} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IResourceService.ts b/src/core/domains/express/interfaces/IResourceService.ts new file mode 100644 index 000000000..5bd8398f7 --- /dev/null +++ b/src/core/domains/express/interfaces/IResourceService.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-unused-vars */ +import { Response } from "express"; + +import { BaseRequest } from "../types/BaseRequest.t"; +import { IRouteResourceOptions } from "./IRouteResourceOptions"; + +export interface IResourceService { + handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise +} + +export interface IPageOptions { + page: number; + pageSize?: number; + skip?: number; +} \ No newline at end of file diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 83fbb8088..f3edf1961 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -1,8 +1,8 @@ import ModelScopes from "@src/core/domains/auth/services/ModelScopes"; import baseAction from "@src/core/domains/express/actions/baseAction"; +import resourceAll from "@src/core/domains/express/actions/resourceAll"; import resourceCreate from "@src/core/domains/express/actions/resourceCreate"; import resourceDelete from "@src/core/domains/express/actions/resourceDelete"; -import resourceIndex from "@src/core/domains/express/actions/resourceIndex"; import resourceShow from "@src/core/domains/express/actions/resourceShow"; import resourceUpdate from "@src/core/domains/express/actions/resourceUpdate"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; @@ -54,14 +54,14 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const routes = RouteGroup([ // Get all resources Route({ - name: `${path}.index`, + name: `${path}.all`, resourceType: RouteResourceTypes.ALL, scopes, scopesPartial: ModelScopes.getScopes(resource, ['read', 'all']), enableScopes, method: 'get', path: `/${path}`, - action: baseAction(options, resourceIndex), + action: baseAction(options, resourceAll), middlewares: options.middlewares, security: options.security }), diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts new file mode 100644 index 000000000..db4d1e28e --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -0,0 +1,144 @@ +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + +import { IPageOptions, IResourceService } from "../../interfaces/IResourceService"; +import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import responseError from "../../requests/responseError"; +import { RouteResourceTypes } from "../../routing/RouteResource"; +import { BaseRequest } from "../../types/BaseRequest.t"; +import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; +import Paginate from "../Paginate"; +import { ALWAYS } from "../Security"; +import SecurityReader from "../SecurityReader"; +import { SecurityIdentifiers } from "../SecurityRules"; + + +class ResourceAllService implements IResourceService { + + /** + * Handles the resource all action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the filters + * - Fetches the results using the filters and page options + * - Maps the results to models + * - Strips the guarded properties from the results + * - Sends the results back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + + // Build the page options, filters + const pageOptions = this.buildPageOptions(req, options); + let filters = this.buildFilters(options); + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + + filters = { + ...filters, + [propertyKey]: App.container('requestContext').getByRequest(req, 'userId') + } + } + + // Fetch the results + const results = await this.fetchResults(options, filters, pageOptions) + const resultsAsModels = results.map((result) => new options.resource(result)); + + // Send the results + res.send(stripGuardedResourceProperties(resultsAsModels)) + } + + /** + * Fetches the results from the database + * + * @param {object} filters - The filters to use when fetching the results + * @param {IPageOptions} pageOptions - The page options to use when fetching the results + * @returns {Promise} - A promise that resolves to the fetched results as an array of models + */ + async fetchResults(options: IRouteResourceOptions, filters: object, pageOptions: IPageOptions): Promise { + const tableName = (new options.resource).table; + const documentManager = App.container('db').documentManager().table(tableName); + + return await documentManager.findMany({ + filter: filters, + limit: pageOptions.pageSize, + skip: pageOptions.skip, + }) + } + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized and resource owner security is set + */ + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { + return true; + } + + return false; + } + + /** + * Checks if the request is authorized to perform the action + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized + */ + validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + return false; + } + + return true; + } + + /** + * Builds the filters object + * + * @param {IRouteResourceOptions} options - The options object + * @returns {object} - The filters object + */ + buildFilters(options: IRouteResourceOptions): object { + return options.allFilters ?? {}; + } + + /** + * Builds the page options + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {IPageOptions} - An object containing the page number, page size, and skip + */ + buildPageOptions(req: BaseRequest, options: IRouteResourceOptions): IPageOptions { + const paginate = new Paginate().parseRequest(req, options.paginate); + const page = paginate.getPage(1); + const pageSize = paginate.getPageSize() ?? options?.paginate?.pageSize; + const skip = pageSize ? (page - 1) * pageSize : undefined; + + return { skip, page, pageSize }; + } + +} + +export default ResourceAllService; \ No newline at end of file diff --git a/src/core/domains/express/utils/stripGuardedResourceProperties.ts b/src/core/domains/express/utils/stripGuardedResourceProperties.ts new file mode 100644 index 000000000..4616f507c --- /dev/null +++ b/src/core/domains/express/utils/stripGuardedResourceProperties.ts @@ -0,0 +1,6 @@ +import { IModel } from "@src/core/interfaces/IModel"; +import IModelData from "@src/core/interfaces/IModelData"; + +const stripGuardedResourceProperties = (results: IModel[]) => results.map(result => result.getData({ excludeGuarded: true }) as IModel); + +export default stripGuardedResourceProperties \ No newline at end of file From 0f51ba46373944632f17ecedbb12989c62231999 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 13:19:10 +0100 Subject: [PATCH 53/76] feat(express): Added ResourceErrorService --- .../domains/express/actions/resourceAll.ts | 9 +-- .../Resources/ResourceErrorService.ts | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 src/core/domains/express/services/Resources/ResourceErrorService.ts diff --git a/src/core/domains/express/actions/resourceAll.ts b/src/core/domains/express/actions/resourceAll.ts index a0b8a93c7..c1d872fcb 100644 --- a/src/core/domains/express/actions/resourceAll.ts +++ b/src/core/domains/express/actions/resourceAll.ts @@ -1,9 +1,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; import ResourceAllService from '../services/Resources/ResourceAllService'; +import ResourceErrorService from '../services/Resources/ResourceErrorService'; /** @@ -20,11 +20,6 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp resourceAllService.handler(req, res, options); } catch (err) { - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceErrorService.ts b/src/core/domains/express/services/Resources/ResourceErrorService.ts new file mode 100644 index 000000000..7b2d103c8 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceErrorService.ts @@ -0,0 +1,58 @@ +import { EnvironmentDevelopment } from "@src/core/consts/Environment"; +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + +import responseError from "../../requests/responseError"; +import { BaseRequest } from "../../types/BaseRequest.t"; + +class ResourceErrorService { + + /** + * Handles an error by sending an appropriate error response to the client. + * + * If the error is a ModelNotFound, it will be sent as a 404. + * If the error is a ForbiddenResourceError, it will be sent as a 403. + * If the error is an UnauthorizedError, it will be sent as a 401. + * If the error is an Error, it will be sent as a 500. + * If the error is anything else, it will be sent as a 500. + * + * @param req The Express Request object + * @param res The Express Response object + * @param err The error to handle + */ + public static handleError(req: BaseRequest, res: Response, err: unknown):void { + if(err instanceof ModelNotFound) { + responseError(req, res, err, 404) + return; + } + + if(err instanceof ForbiddenResourceError) { + responseError(req, res, err, 403) + return; + } + + if(err instanceof UnauthorizedError) { + responseError(req, res, err, 401) + return; + } + + if (err instanceof Error) { + responseError(req, res, err) + return; + } + + let error = 'Something went wrong.' + + if(App.env() === EnvironmentDevelopment) { + error = (err as Error)?.message ?? error + } + + res.status(500).send({ error }) + } + +} + +export default ResourceErrorService \ No newline at end of file From d907b954eaff185313b81f2e32cee952d9b697f9 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 13:19:48 +0100 Subject: [PATCH 54/76] refactor(express): Refactored resource show into a service --- .../domains/express/actions/resourceShow.ts | 85 +---------- .../express/interfaces/IResourceService.ts | 4 +- .../services/Resources/BaseResourceService.ts | 52 +++++++ .../services/Resources/ResourceAllService.ts | 56 ++----- .../services/Resources/ResourceShowService.ts | 139 ++++++++++++++++++ .../utils/stripGuardedResourceProperties.ts | 8 +- 6 files changed, 222 insertions(+), 122 deletions(-) create mode 100644 src/core/domains/express/services/Resources/BaseResourceService.ts create mode 100644 src/core/domains/express/services/Resources/ResourceShowService.ts diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 454e764ef..abe8d5927 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,18 +1,10 @@ -import Repository from '@src/core/base/Repository'; -import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS } from '@src/core/domains/express/services/Security'; -import SecurityReader from '@src/core/domains/express/services/SecurityReader'; -import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; -import { IModel } from '@src/core/interfaces/IModel'; -import { App } from '@src/core/services/App'; import { Response } from 'express'; +import ResourceErrorService from '../services/Resources/ResourceErrorService'; +import ResourceShowService from '../services/Resources/ResourceShowService'; + /** * Finds a resource by id * @@ -23,75 +15,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.SHOW]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.SHOW, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - const repository = new Repository(options.resource); - - let result: IModel | null = null; - - // Define our query filters - const filters: object = { - ...(options.showFilters ?? {}), - id: req.params?.id - }; - - /** - * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user - */ - if(resourceOwnerSecurity && authorizationSecurity) { - - const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('requestContext').getByRequest(req, 'userId'); - - if(!userId) { - responseError(req, res, new ForbiddenResourceError(), 403); - return; - } - - if(typeof propertyKey !== 'string') { - throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); - } - - result = await repository.findOne({ - ...filters, - [propertyKey]: userId - }) - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - res.send(result?.getData({ excludeGuarded: true }) as IModel); - - return; - } - - /** - * Find resource without restrictions - */ - result = await repository.findOne(filters); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - res.send(result?.getData({ excludeGuarded: true }) as IModel); + const resourceShowService = new ResourceShowService() + resourceShowService.handler(req, res, options) } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - return; - } - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IResourceService.ts b/src/core/domains/express/interfaces/IResourceService.ts index 5bd8398f7..89dc36918 100644 --- a/src/core/domains/express/interfaces/IResourceService.ts +++ b/src/core/domains/express/interfaces/IResourceService.ts @@ -4,8 +4,10 @@ import { Response } from "express"; import { BaseRequest } from "../types/BaseRequest.t"; import { IRouteResourceOptions } from "./IRouteResourceOptions"; +export type IPartialRouteResourceOptions = Omit + export interface IResourceService { - handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise + handler(req: BaseRequest, res: Response, options: IPartialRouteResourceOptions): Promise } export interface IPageOptions { diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts new file mode 100644 index 000000000..83accd955 --- /dev/null +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -0,0 +1,52 @@ +import { Response } from "express"; + +import { IResourceService } from "../../interfaces/IResourceService"; +import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "../../routing/RouteResource"; +import { BaseRequest } from "../../types/BaseRequest.t"; +import { ALWAYS } from "../Security"; +import SecurityReader from "../SecurityReader"; +import { SecurityIdentifiers } from "../SecurityRules"; + +abstract class BaseResourceService implements IResourceService { + + // eslint-disable-next-line no-unused-vars + abstract handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise; + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized and resource owner security is set + */ + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { + return true; + } + + return false; + } + + /** + * Checks if the request is authorized to perform the action + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized + */ + validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + return false; + } + + return true; + } + +} + +export default BaseResourceService \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts index db4d1e28e..d3e068232 100644 --- a/src/core/domains/express/services/Resources/ResourceAllService.ts +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -1,21 +1,21 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; import { IModel } from "@src/core/interfaces/IModel"; import { App } from "@src/core/services/App"; import { Response } from "express"; -import { IPageOptions, IResourceService } from "../../interfaces/IResourceService"; +import { IPageOptions } from "../../interfaces/IResourceService"; import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import responseError from "../../requests/responseError"; import { RouteResourceTypes } from "../../routing/RouteResource"; import { BaseRequest } from "../../types/BaseRequest.t"; import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; import Paginate from "../Paginate"; -import { ALWAYS } from "../Security"; import SecurityReader from "../SecurityReader"; import { SecurityIdentifiers } from "../SecurityRules"; +import BaseResourceService from "./BaseResourceService"; -class ResourceAllService implements IResourceService { +class ResourceAllService extends BaseResourceService { /** * Handles the resource all action @@ -33,8 +33,7 @@ class ResourceAllService implements IResourceService { // Check if the authorization security applies to this route and it is valid if(!this.validateAuthorization(req, options)) { - responseError(req, res, new UnauthorizedError(), 401) - return; + throw new UnauthorizedError() } // Build the page options, filters @@ -46,10 +45,19 @@ class ResourceAllService implements IResourceService { if(this.validateResourceOwner(req, options)) { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } filters = { ...filters, - [propertyKey]: App.container('requestContext').getByRequest(req, 'userId') + [propertyKey]: userId } } @@ -79,40 +87,6 @@ class ResourceAllService implements IResourceService { }) } - /** - * Checks if the request is authorized to perform the action and if the resource owner security is set - * - * @param {BaseRequest} req - The request object - * @param {IRouteResourceOptions} options - The options object - * @returns {boolean} - Whether the request is authorized and resource owner security is set - */ - validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) - - if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { - return true; - } - - return false; - } - - /** - * Checks if the request is authorized to perform the action - * - * @param {BaseRequest} req - The request object - * @param {IRouteResourceOptions} options - The options object - * @returns {boolean} - Whether the request is authorized - */ - validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - return false; - } - - return true; - } - /** * Builds the filters object * diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts new file mode 100644 index 000000000..3ef211681 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -0,0 +1,139 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + +import { IPageOptions, IResourceService } from "../../interfaces/IResourceService"; +import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "../../routing/RouteResource"; +import { BaseRequest } from "../../types/BaseRequest.t"; +import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; +import { ALWAYS } from "../Security"; +import SecurityReader from "../SecurityReader"; +import { SecurityIdentifiers } from "../SecurityRules"; + + +class ResourceShowService implements IResourceService { + + + /** + * Handles the resource show action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the filters + * - Fetches the result using the filters + * - Maps the result to a model + * - Strips the guarded properties from the result + * - Sends the result back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + // Build the page options, filters + let filters = this.buildFilters(options); + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + filters = { + ...filters, + [propertyKey]: userId + } + } + + // Fetch the results + const result = await this.fetchRecord(options, filters) + + if (!result) { + throw new ModelNotFound(); + } + + const resultAsModel = new options.resource(result) + + // Send the results + res.send(stripGuardedResourceProperties(resultAsModel)) + } + + /** + * Fetches the results from the database + * + * @param {object} filters - The filters to use when fetching the results + * @param {IPageOptions} pageOptions - The page options to use when fetching the results + * @returns {Promise} - A promise that resolves to the fetched results as an array of models + */ + async fetchRecord(options: IRouteResourceOptions, filters: object): Promise { + const tableName = (new options.resource).table; + const documentManager = App.container('db').documentManager().table(tableName); + + return await documentManager.findOne({ + filter: filters, + }) + } + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized and resource owner security is set + */ + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { + return true; + } + + return false; + } + + /** + * Checks if the request is authorized to perform the action + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @returns {boolean} - Whether the request is authorized + */ + validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + return false; + } + + return true; + } + + /** + * Builds the filters object + * + * @param {IRouteResourceOptions} options - The options object + * @returns {object} - The filters object + */ + buildFilters(options: IRouteResourceOptions): object { + return options.showFilters ?? {}; + } + +} + +export default ResourceShowService; \ No newline at end of file diff --git a/src/core/domains/express/utils/stripGuardedResourceProperties.ts b/src/core/domains/express/utils/stripGuardedResourceProperties.ts index 4616f507c..52bf82835 100644 --- a/src/core/domains/express/utils/stripGuardedResourceProperties.ts +++ b/src/core/domains/express/utils/stripGuardedResourceProperties.ts @@ -1,6 +1,12 @@ import { IModel } from "@src/core/interfaces/IModel"; import IModelData from "@src/core/interfaces/IModelData"; -const stripGuardedResourceProperties = (results: IModel[]) => results.map(result => result.getData({ excludeGuarded: true }) as IModel); +const stripGuardedResourceProperties = (results: IModel[] | IModel) => { + if(!Array.isArray(results)) { + return results.getData({ excludeGuarded: true }) as IModel + } + + return results.map(result => result.getData({ excludeGuarded: true }) as IModel); +} export default stripGuardedResourceProperties \ No newline at end of file From 7f78407835d1ca20a9430c9719893da2a4308b90 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 13:38:37 +0100 Subject: [PATCH 55/76] refactor(express): Refactored resource create into a service --- .../domains/express/actions/resourceCreate.ts | 57 ++-------------- .../services/Resources/BaseResourceService.ts | 21 +++++- .../services/Resources/ResourceAllService.ts | 6 +- .../Resources/ResourceCreateService.ts | 65 +++++++++++++++++++ .../services/Resources/ResourceShowService.ts | 47 ++------------ 5 files changed, 98 insertions(+), 98 deletions(-) create mode 100644 src/core/domains/express/services/Resources/ResourceCreateService.ts diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 1da654d30..bc8e38e99 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,15 +1,10 @@ -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS } from '@src/core/domains/express/services/Security'; -import SecurityReader from '@src/core/domains/express/services/SecurityReader'; -import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { App } from '@src/core/services/App'; import { Response } from 'express'; +import ResourceCreateService from '../services/Resources/ResourceCreateService'; +import ResourceErrorService from '../services/Resources/ResourceErrorService'; + /** * Creates a new instance of the model @@ -21,50 +16,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.CREATE]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.CREATE, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - - const modelInstance = new options.resource(req.body); - - /** - * When a resourceOwnerSecurity is defined, we need to set the record that is owned by the user - */ - if(resourceOwnerSecurity) { - - if(!authorizationSecurity) { - responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); - } - - const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('requestContext').getByRequest(req, 'userId'); - - if(typeof propertyKey !== 'string') { - throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); - } - - if(!userId) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - - modelInstance.setAttribute(propertyKey, userId) - } - - await modelInstance.save(); - - res.status(201).send(modelInstance.getData({ excludeGuarded: true }) as IRouteResourceOptions['resource']) + const resourceCreateService = new ResourceCreateService(); + resourceCreateService.handler(req, res, options); } catch (err) { - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts index 83accd955..7d81ebe7e 100644 --- a/src/core/domains/express/services/Resources/BaseResourceService.ts +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -1,7 +1,8 @@ import { Response } from "express"; -import { IResourceService } from "../../interfaces/IResourceService"; +import { IPartialRouteResourceOptions, IResourceService } from "../../interfaces/IResourceService"; import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import { IIdentifiableSecurityCallback } from "../../interfaces/ISecurity"; import { RouteResourceTypes } from "../../routing/RouteResource"; import { BaseRequest } from "../../types/BaseRequest.t"; import { ALWAYS } from "../Security"; @@ -10,6 +11,11 @@ import { SecurityIdentifiers } from "../SecurityRules"; abstract class BaseResourceService implements IResourceService { + /** + * The route resource type (RouteResourceTypes) + */ + abstract routeResourceType: string; + // eslint-disable-next-line no-unused-vars abstract handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise; @@ -20,8 +26,8 @@ abstract class BaseResourceService implements IResourceService { * @param {IRouteResourceOptions} options - The options object * @returns {boolean} - Whether the request is authorized and resource owner security is set */ - validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions, when: string[] = [this.routeResourceType]): boolean { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, when) if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { return true; @@ -47,6 +53,15 @@ abstract class BaseResourceService implements IResourceService { return true; } + /** + * Finds the resource owner security from the given options + * @param {IRouteResourceOptions} options - The options object + * @returns {IIdentifiableSecurityCallback | undefined} - The found resource owner security or undefined if not found + */ + getResourceOwnerSecurity(options: IPartialRouteResourceOptions): IIdentifiableSecurityCallback | undefined { + return SecurityReader.findFromRouteResourceOptions(options as IRouteResourceOptions, SecurityIdentifiers.RESOURCE_OWNER, [this.routeResourceType]); + } + } export default BaseResourceService \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts index d3e068232..b22c56d88 100644 --- a/src/core/domains/express/services/Resources/ResourceAllService.ts +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -10,13 +10,13 @@ import { RouteResourceTypes } from "../../routing/RouteResource"; import { BaseRequest } from "../../types/BaseRequest.t"; import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; import Paginate from "../Paginate"; -import SecurityReader from "../SecurityReader"; -import { SecurityIdentifiers } from "../SecurityRules"; import BaseResourceService from "./BaseResourceService"; class ResourceAllService extends BaseResourceService { + routeResourceType: string = RouteResourceTypes.ALL + /** * Handles the resource all action * - Validates that the request is authorized @@ -43,7 +43,7 @@ class ResourceAllService extends BaseResourceService { // Check if the resource owner security applies to this route and it is valid // If it is valid, we add the owner's id to the filters if(this.validateResourceOwner(req, options)) { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) const propertyKey = resourceOwnerSecurity?.arguements?.key as string; const userId = App.container('requestContext').getByRequest(req, 'userId'); diff --git a/src/core/domains/express/services/Resources/ResourceCreateService.ts b/src/core/domains/express/services/Resources/ResourceCreateService.ts new file mode 100644 index 000000000..3251c0140 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceCreateService.ts @@ -0,0 +1,65 @@ +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { App } from "@src/core/services/App"; +import { Response } from "express"; + +import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "../../routing/RouteResource"; +import { BaseRequest } from "../../types/BaseRequest.t"; +import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; +import BaseResourceService from "./BaseResourceService"; + + +class ResourceCreateService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.CREATE + + /** + * Handles the resource create action + * - Validates that the request is authorized + * - If the resource owner security is enabled, adds the owner's id to the model properties + * - Creates a new model instance with the request body + * - Saves the model instance + * - Strips the guarded properties from the model instance + * - Sends the model instance back to the client + * @param req The request object + * @param res The response object + * @param options The resource options + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + // Build the page options, filters + const modalInstance = new options.resource(req.body); + + // Check if the resource owner security applies to this route and it is valid + // If it is valid, we add the owner's id to the filters + if(this.validateResourceOwner(req, options)) { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) + const propertyKey = resourceOwnerSecurity?.arguements?.key as string; + const userId = App.container('requestContext').getByRequest(req, 'userId'); + + if(!userId) { + throw new ForbiddenResourceError() + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + modalInstance.setAttribute(propertyKey, userId) + } + + await modalInstance.save(); + + // Send the results + res.status(201).send(stripGuardedResourceProperties(modalInstance)) + } + +} + +export default ResourceCreateService; \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts index 3ef211681..bfe936fb7 100644 --- a/src/core/domains/express/services/Resources/ResourceShowService.ts +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -5,19 +5,18 @@ import { IModel } from "@src/core/interfaces/IModel"; import { App } from "@src/core/services/App"; import { Response } from "express"; -import { IPageOptions, IResourceService } from "../../interfaces/IResourceService"; +import { IPageOptions } from "../../interfaces/IResourceService"; import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; import { RouteResourceTypes } from "../../routing/RouteResource"; import { BaseRequest } from "../../types/BaseRequest.t"; import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; -import { ALWAYS } from "../Security"; -import SecurityReader from "../SecurityReader"; -import { SecurityIdentifiers } from "../SecurityRules"; +import BaseResourceService from "./BaseResourceService"; -class ResourceShowService implements IResourceService { +class ResourceShowService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.SHOW - /** * Handles the resource show action * - Validates that the request is authorized @@ -43,7 +42,7 @@ class ResourceShowService implements IResourceService { // Check if the resource owner security applies to this route and it is valid // If it is valid, we add the owner's id to the filters if(this.validateResourceOwner(req, options)) { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options) const propertyKey = resourceOwnerSecurity?.arguements?.key as string; const userId = App.container('requestContext').getByRequest(req, 'userId'); @@ -90,40 +89,6 @@ class ResourceShowService implements IResourceService { }) } - /** - * Checks if the request is authorized to perform the action and if the resource owner security is set - * - * @param {BaseRequest} req - The request object - * @param {IRouteResourceOptions} options - The options object - * @returns {boolean} - Whether the request is authorized and resource owner security is set - */ - validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) - - if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { - return true; - } - - return false; - } - - /** - * Checks if the request is authorized to perform the action - * - * @param {BaseRequest} req - The request object - * @param {IRouteResourceOptions} options - The options object - * @returns {boolean} - Whether the request is authorized - */ - validateAuthorization(req: BaseRequest, options: IRouteResourceOptions): boolean { - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - return false; - } - - return true; - } - /** * Builds the filters object * From e0c62b769e8707898a1d2801471466a976700b03 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 13:49:39 +0100 Subject: [PATCH 56/76] refactor(express): Refactored resource delete into a service --- .../domains/express/actions/resourceDelete.ts | 48 +++-------------- .../services/Resources/BaseResourceService.ts | 19 +++++++ .../Resources/ResourceDeleteService.ts | 54 +++++++++++++++++++ 3 files changed, 79 insertions(+), 42 deletions(-) create mode 100644 src/core/domains/express/services/Resources/ResourceDeleteService.ts diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index a042ec504..49985dcbf 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,16 +1,10 @@ -import Repository from '@src/core/base/Repository'; -import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS } from '@src/core/domains/express/services/Security'; -import SecurityReader from '@src/core/domains/express/services/SecurityReader'; -import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; +import ResourceDeleteService from '../services/Resources/ResourceDeleteService'; +import ResourceErrorService from '../services/Resources/ResourceErrorService'; + /** * Deletes a resource * @@ -21,40 +15,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.DESTROY]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.DESTROY, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - const repository = new Repository(options.resource); - - const result = await repository.findById(req.params?.id); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { - responseError(req, res, new ForbiddenResourceError(), 403) - return; - } - - await result.delete(); - - res.send({ success: true }) + const resourceDeleteService = new ResourceDeleteService(); + resourceDeleteService.handler(req, res, options); } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - return; - } - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts index 7d81ebe7e..4cbfdd9ce 100644 --- a/src/core/domains/express/services/Resources/BaseResourceService.ts +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -1,3 +1,4 @@ +import { IModel } from "@src/core/interfaces/IModel"; import { Response } from "express"; import { IPartialRouteResourceOptions, IResourceService } from "../../interfaces/IResourceService"; @@ -35,6 +36,24 @@ abstract class BaseResourceService implements IResourceService { return false; } + + /** + * Checks if the request is authorized to perform the action and if the resource owner security is set on the given resource instance + * + * @param {BaseRequest} req - The request object + * @param {IRouteResourceOptions} options - The options object + * @param {IModel} resourceInstance - The resource instance + * @returns {boolean} - Whether the request is authorized and resource owner security is set on the given resource instance + */ + validateResourceOwnerCallback(req: BaseRequest, options: IRouteResourceOptions, resourceInstance: IModel): boolean { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [this.routeResourceType]) + + if(this.validateAuthorization(req, options) && resourceOwnerSecurity?.callback(req, resourceInstance)) { + return true; + } + + return false; + } /** * Checks if the request is authorized to perform the action diff --git a/src/core/domains/express/services/Resources/ResourceDeleteService.ts b/src/core/domains/express/services/Resources/ResourceDeleteService.ts new file mode 100644 index 000000000..1d4ad0632 --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceDeleteService.ts @@ -0,0 +1,54 @@ +import Repository from "@src/core/base/Repository"; +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { Response } from "express"; + +import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "../../routing/RouteResource"; +import { BaseRequest } from "../../types/BaseRequest.t"; +import BaseResourceService from "./BaseResourceService"; + + +class ResourceDeleteService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.DESTROY + + /** + * Handles the resource delete action + * - Validates that the request is authorized + * - Checks if the resource owner security applies to this route and it is valid + * - Deletes the resource + * - Sends the results back to the client + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {IRouteResourceOptions} options - The options object + * @returns {Promise} + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + const repository = new Repository(options.resource) + + const result = await repository.findById(req.params?.id) + + if(!result) { + throw new ModelNotFound() + } + + // Check if the resource owner security applies to this route and it is valid + if(this.validateResourceOwnerCallback(req, options, result)) { + throw new ForbiddenResourceError() + } + + // Send the results + res.send({ success: true }) + } + +} + +export default ResourceDeleteService; \ No newline at end of file From d2fa72f6085b3bd0651abebf582bbce63c1a9074 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 20:20:25 +0100 Subject: [PATCH 57/76] refactor(express): Refactored resource update into its own service, added awaits to all resource service handlers --- .../domains/express/actions/resourceAll.ts | 8 +-- .../domains/express/actions/resourceCreate.ts | 7 +-- .../domains/express/actions/resourceDelete.ts | 7 +-- .../domains/express/actions/resourceShow.ts | 7 +-- .../domains/express/actions/resourceUpdate.ts | 54 ++---------------- .../express/interfaces/IResourceService.ts | 5 +- .../services/Resources/BaseResourceService.ts | 23 ++++---- .../services/Resources/ResourceAllService.ts | 15 +++-- .../Resources/ResourceCreateService.ts | 11 ++-- .../Resources/ResourceDeleteService.ts | 11 ++-- .../Resources/ResourceErrorService.ts | 15 +---- .../services/Resources/ResourceShowService.ts | 13 ++--- .../Resources/ResourceUpdateService.ts | 57 +++++++++++++++++++ 13 files changed, 113 insertions(+), 120 deletions(-) create mode 100644 src/core/domains/express/services/Resources/ResourceUpdateService.ts diff --git a/src/core/domains/express/actions/resourceAll.ts b/src/core/domains/express/actions/resourceAll.ts index c1d872fcb..36f5d99e3 100644 --- a/src/core/domains/express/actions/resourceAll.ts +++ b/src/core/domains/express/actions/resourceAll.ts @@ -1,11 +1,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import ResourceAllService from '@src/core/domains/express/services/Resources/ResourceAllService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; -import ResourceAllService from '../services/Resources/ResourceAllService'; -import ResourceErrorService from '../services/Resources/ResourceErrorService'; - - /** * Finds all records in the resource's repository * @@ -17,7 +15,7 @@ import ResourceErrorService from '../services/Resources/ResourceErrorService'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceAllService = new ResourceAllService(); - resourceAllService.handler(req, res, options); + await resourceAllService.handler(req, res, options); } catch (err) { ResourceErrorService.handleError(req, res, err) diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index bc8e38e99..22549134a 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,10 +1,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import ResourceCreateService from '@src/core/domains/express/services/Resources/ResourceCreateService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; -import ResourceCreateService from '../services/Resources/ResourceCreateService'; -import ResourceErrorService from '../services/Resources/ResourceErrorService'; - /** * Creates a new instance of the model @@ -17,7 +16,7 @@ import ResourceErrorService from '../services/Resources/ResourceErrorService'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceCreateService = new ResourceCreateService(); - resourceCreateService.handler(req, res, options); + await resourceCreateService.handler(req, res, options); } catch (err) { ResourceErrorService.handleError(req, res, err) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 49985dcbf..f90a47f66 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,10 +1,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import ResourceDeleteService from '@src/core/domains/express/services/Resources/ResourceDeleteService'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; -import ResourceDeleteService from '../services/Resources/ResourceDeleteService'; -import ResourceErrorService from '../services/Resources/ResourceErrorService'; - /** * Deletes a resource * @@ -16,7 +15,7 @@ import ResourceErrorService from '../services/Resources/ResourceErrorService'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceDeleteService = new ResourceDeleteService(); - resourceDeleteService.handler(req, res, options); + await resourceDeleteService.handler(req, res, options); } catch (err) { ResourceErrorService.handleError(req, res, err) diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index abe8d5927..3b4a8abd2 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,10 +1,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; +import ResourceShowService from '@src/core/domains/express/services/Resources/ResourceShowService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; -import ResourceErrorService from '../services/Resources/ResourceErrorService'; -import ResourceShowService from '../services/Resources/ResourceShowService'; - /** * Finds a resource by id * @@ -16,7 +15,7 @@ import ResourceShowService from '../services/Resources/ResourceShowService'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceShowService = new ResourceShowService() - resourceShowService.handler(req, res, options) + await resourceShowService.handler(req, res, options) } catch (err) { ResourceErrorService.handleError(req, res, err) diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index ce9f9a0a0..14b3bc050 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,16 +1,7 @@ -import Repository from '@src/core/base/Repository'; -import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; -import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; -import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; -import responseError from '@src/core/domains/express/requests/responseError'; -import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; -import { ALWAYS } from '@src/core/domains/express/services/Security'; -import SecurityReader from '@src/core/domains/express/services/SecurityReader'; -import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; +import ResourceErrorService from '@src/core/domains/express/services/Resources/ResourceErrorService'; +import ResourceUpdateService from '@src/core/domains/express/services/Resources/ResourceUpdateService'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import ModelNotFound from '@src/core/exceptions/ModelNotFound'; -import { IModel } from '@src/core/interfaces/IModel'; import { Response } from 'express'; /** @@ -23,45 +14,10 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.UPDATE]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.UPDATE, ALWAYS]); - - if(authorizationSecurity && !authorizationSecurity.callback(req)) { - responseError(req, res, new UnauthorizedError(), 401) - return; - } - const repository = new Repository(options.resource); - - const result = await repository.findById(req.params?.id); - - if (!result) { - throw new ModelNotFound('Resource not found'); - } - - if(resourceOwnerSecurity && !authorizationSecurity) { - responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); - } - - if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { - responseError(req, res, new ForbiddenResourceError(), 403) - return; - } - - result.fill(req.body); - await result.save(); - - res.send(result?.getData({ excludeGuarded: true }) as IModel); + const resourceUpdateService = new ResourceUpdateService(); + await resourceUpdateService.handler(req, res, options); } catch (err) { - if(err instanceof ModelNotFound) { - responseError(req, res, err, 404) - return; - } - if (err instanceof Error) { - responseError(req, res, err) - return; - } - - res.status(500).send({ error: 'Something went wrong' }) + ResourceErrorService.handleError(req, res, err) } } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IResourceService.ts b/src/core/domains/express/interfaces/IResourceService.ts index 89dc36918..3bd52c8dd 100644 --- a/src/core/domains/express/interfaces/IResourceService.ts +++ b/src/core/domains/express/interfaces/IResourceService.ts @@ -1,9 +1,8 @@ /* eslint-disable no-unused-vars */ +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from "express"; -import { BaseRequest } from "../types/BaseRequest.t"; -import { IRouteResourceOptions } from "./IRouteResourceOptions"; - export type IPartialRouteResourceOptions = Omit export interface IResourceService { diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts index 4cbfdd9ce..0bb818d85 100644 --- a/src/core/domains/express/services/Resources/BaseResourceService.ts +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -1,15 +1,14 @@ +import { IPartialRouteResourceOptions, IResourceService } from "@src/core/domains/express/interfaces/IResourceService"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import { ALWAYS } from "@src/core/domains/express/services/Security"; +import SecurityReader from "@src/core/domains/express/services/SecurityReader"; +import { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from "@src/core/interfaces/IModel"; import { Response } from "express"; -import { IPartialRouteResourceOptions, IResourceService } from "../../interfaces/IResourceService"; -import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import { IIdentifiableSecurityCallback } from "../../interfaces/ISecurity"; -import { RouteResourceTypes } from "../../routing/RouteResource"; -import { BaseRequest } from "../../types/BaseRequest.t"; -import { ALWAYS } from "../Security"; -import SecurityReader from "../SecurityReader"; -import { SecurityIdentifiers } from "../SecurityRules"; - abstract class BaseResourceService implements IResourceService { /** @@ -27,8 +26,8 @@ abstract class BaseResourceService implements IResourceService { * @param {IRouteResourceOptions} options - The options object * @returns {boolean} - Whether the request is authorized and resource owner security is set */ - validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions, when: string[] = [this.routeResourceType]): boolean { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, when) + validateResourceOwner(req: BaseRequest, options: IRouteResourceOptions): boolean { + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options); if(this.validateAuthorization(req, options) && resourceOwnerSecurity ) { return true; @@ -46,7 +45,7 @@ abstract class BaseResourceService implements IResourceService { * @returns {boolean} - Whether the request is authorized and resource owner security is set on the given resource instance */ validateResourceOwnerCallback(req: BaseRequest, options: IRouteResourceOptions, resourceInstance: IModel): boolean { - const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [this.routeResourceType]) + const resourceOwnerSecurity = this.getResourceOwnerSecurity(options); if(this.validateAuthorization(req, options) && resourceOwnerSecurity?.callback(req, resourceInstance)) { return true; diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts index b22c56d88..dbe8365a5 100644 --- a/src/core/domains/express/services/Resources/ResourceAllService.ts +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -1,17 +1,16 @@ import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IPageOptions } from "@src/core/domains/express/interfaces/IResourceService"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import Paginate from "@src/core/domains/express/services/Paginate"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; import { IModel } from "@src/core/interfaces/IModel"; import { App } from "@src/core/services/App"; import { Response } from "express"; -import { IPageOptions } from "../../interfaces/IResourceService"; -import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import { RouteResourceTypes } from "../../routing/RouteResource"; -import { BaseRequest } from "../../types/BaseRequest.t"; -import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; -import Paginate from "../Paginate"; -import BaseResourceService from "./BaseResourceService"; - class ResourceAllService extends BaseResourceService { diff --git a/src/core/domains/express/services/Resources/ResourceCreateService.ts b/src/core/domains/express/services/Resources/ResourceCreateService.ts index 3251c0140..378a05d84 100644 --- a/src/core/domains/express/services/Resources/ResourceCreateService.ts +++ b/src/core/domains/express/services/Resources/ResourceCreateService.ts @@ -1,14 +1,13 @@ import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; import { App } from "@src/core/services/App"; import { Response } from "express"; -import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import { RouteResourceTypes } from "../../routing/RouteResource"; -import { BaseRequest } from "../../types/BaseRequest.t"; -import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; -import BaseResourceService from "./BaseResourceService"; - class ResourceCreateService extends BaseResourceService { diff --git a/src/core/domains/express/services/Resources/ResourceDeleteService.ts b/src/core/domains/express/services/Resources/ResourceDeleteService.ts index 1d4ad0632..7a7e49c5e 100644 --- a/src/core/domains/express/services/Resources/ResourceDeleteService.ts +++ b/src/core/domains/express/services/Resources/ResourceDeleteService.ts @@ -1,14 +1,13 @@ import Repository from "@src/core/base/Repository"; import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from "@src/core/exceptions/ModelNotFound"; import { Response } from "express"; -import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import { RouteResourceTypes } from "../../routing/RouteResource"; -import { BaseRequest } from "../../types/BaseRequest.t"; -import BaseResourceService from "./BaseResourceService"; - class ResourceDeleteService extends BaseResourceService { @@ -41,7 +40,7 @@ class ResourceDeleteService extends BaseResourceService { } // Check if the resource owner security applies to this route and it is valid - if(this.validateResourceOwnerCallback(req, options, result)) { + if(!this.validateResourceOwnerCallback(req, options, result)) { throw new ForbiddenResourceError() } diff --git a/src/core/domains/express/services/Resources/ResourceErrorService.ts b/src/core/domains/express/services/Resources/ResourceErrorService.ts index 7b2d103c8..e8fd7cab0 100644 --- a/src/core/domains/express/services/Resources/ResourceErrorService.ts +++ b/src/core/domains/express/services/Resources/ResourceErrorService.ts @@ -1,13 +1,10 @@ -import { EnvironmentDevelopment } from "@src/core/consts/Environment"; import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import responseError from "@src/core/domains/express/requests/responseError"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from "@src/core/exceptions/ModelNotFound"; -import { App } from "@src/core/services/App"; import { Response } from "express"; -import responseError from "../../requests/responseError"; -import { BaseRequest } from "../../types/BaseRequest.t"; - class ResourceErrorService { /** @@ -44,13 +41,7 @@ class ResourceErrorService { return; } - let error = 'Something went wrong.' - - if(App.env() === EnvironmentDevelopment) { - error = (err as Error)?.message ?? error - } - - res.status(500).send({ error }) + res.status(500).send({ error: 'Something went wrong.' }) } } diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts index bfe936fb7..e079188b8 100644 --- a/src/core/domains/express/services/Resources/ResourceShowService.ts +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -1,17 +1,16 @@ import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IPageOptions } from "@src/core/domains/express/interfaces/IResourceService"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; import ModelNotFound from "@src/core/exceptions/ModelNotFound"; import { IModel } from "@src/core/interfaces/IModel"; import { App } from "@src/core/services/App"; import { Response } from "express"; -import { IPageOptions } from "../../interfaces/IResourceService"; -import { IRouteResourceOptions } from "../../interfaces/IRouteResourceOptions"; -import { RouteResourceTypes } from "../../routing/RouteResource"; -import { BaseRequest } from "../../types/BaseRequest.t"; -import stripGuardedResourceProperties from "../../utils/stripGuardedResourceProperties"; -import BaseResourceService from "./BaseResourceService"; - class ResourceShowService extends BaseResourceService { diff --git a/src/core/domains/express/services/Resources/ResourceUpdateService.ts b/src/core/domains/express/services/Resources/ResourceUpdateService.ts new file mode 100644 index 000000000..4aafc25bc --- /dev/null +++ b/src/core/domains/express/services/Resources/ResourceUpdateService.ts @@ -0,0 +1,57 @@ +import Repository from "@src/core/base/Repository"; +import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; +import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; +import ModelNotFound from "@src/core/exceptions/ModelNotFound"; +import { Response } from "express"; + + +class ResourceUpdateService extends BaseResourceService { + + routeResourceType: string = RouteResourceTypes.UPDATE + + /** + * Handles the resource delete action + * - Validates that the request is authorized + * - Checks if the resource owner security applies to this route and it is valid + * - Deletes the resource + * - Sends the results back to the client + * @param {BaseRequest} req - The request object + * @param {Response} res - The response object + * @param {IRouteResourceOptions} options - The options object + * @returns {Promise} + */ + async handler(req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise { + + // Check if the authorization security applies to this route and it is valid + if(!this.validateAuthorization(req, options)) { + throw new UnauthorizedError() + } + + const repository = new Repository(options.resource) + + const result = await repository.findById(req.params?.id) + + if (!result) { + throw new ModelNotFound(); + } + + // Check if the resource owner security applies to this route and it is valid + if(!this.validateResourceOwnerCallback(req, options, result)) { + throw new ForbiddenResourceError() + } + + result.fill(req.body); + await result.save(); + + // Send the results + res.send(stripGuardedResourceProperties(result)) + } + +} + +export default ResourceUpdateService; \ No newline at end of file From cc7c4ddb02a5ef8fb26b25d9bf54361559c49c71 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 20:21:15 +0100 Subject: [PATCH 58/76] Removed unused import --- .../domains/express/services/Resources/ResourceShowService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts index e079188b8..6fe62dff8 100644 --- a/src/core/domains/express/services/Resources/ResourceShowService.ts +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -1,6 +1,5 @@ import ForbiddenResourceError from "@src/core/domains/auth/exceptions/ForbiddenResourceError"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; -import { IPageOptions } from "@src/core/domains/express/interfaces/IResourceService"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; From 3a13641fa85a8c58cfb89ef10f9a76d42fdc5e7a Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 21:50:15 +0100 Subject: [PATCH 59/76] feature(database): Added partial searching to Postgres Document Manager --- .../database/builder/PostgresQueryBuilder.ts | 17 +++++++++++++---- .../database/interfaces/IDocumentManager.ts | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/domains/database/builder/PostgresQueryBuilder.ts b/src/core/domains/database/builder/PostgresQueryBuilder.ts index 0e94f125f..efccd3288 100644 --- a/src/core/domains/database/builder/PostgresQueryBuilder.ts +++ b/src/core/domains/database/builder/PostgresQueryBuilder.ts @@ -21,7 +21,12 @@ export type SelectOptions = { /** * Filter for query */ - filter?: object + filter?: object; + + /** + * Allow partial search + */ + allowPartialSearch?: boolean /** * Order by @@ -49,11 +54,11 @@ class PostgresQueryBuilder { * @param options Select options * @returns Query string */ - select({ fields, tableName, filter = {}, order = [], limit = undefined, skip = undefined }: SelectOptions): string { + select({ fields, tableName, filter = {}, order = [], limit = undefined, skip = undefined, allowPartialSearch = false }: SelectOptions): string { let queryStr = `SELECT ${this.selectColumnsClause(fields)} FROM "${tableName}"`; if(Object.keys(filter ?? {}).length > 0) { - queryStr += ` WHERE ${this.whereClause(filter)}`; + queryStr += ` WHERE ${this.whereClause(filter, { allowPartialSearch })}` ; } if(order.length > 0) { @@ -99,13 +104,17 @@ class PostgresQueryBuilder { * @param filter Filter * @returns Where clause */ - whereClause(filter: object = {}): string { + whereClause(filter: object = {}, { allowPartialSearch = false } = {}): string { return Object.keys(filter).map((key) => { const value = filter[key]; if(value === null) { return `"${key}" IS NULL`; } + + if(allowPartialSearch && value.startsWith('%') || value.endsWith('%')) { + return `"${key}" LIKE :${key}` + } return `"${key}" = :${key}` }).join(' AND '); diff --git a/src/core/domains/database/interfaces/IDocumentManager.ts b/src/core/domains/database/interfaces/IDocumentManager.ts index 4b4c5a01b..b27710f96 100644 --- a/src/core/domains/database/interfaces/IDocumentManager.ts +++ b/src/core/domains/database/interfaces/IDocumentManager.ts @@ -10,7 +10,7 @@ export interface IDatabaseDocument { } export type OrderOptions = Record[]; -export type FindOptions = { filter?: object, order?: OrderOptions, limit?: number, skip?: number }; +export type FindOptions = { filter?: object, order?: OrderOptions, limit?: number, skip?: number, allowPartialSearch?: boolean, useFuzzySearch?: boolean }; /** * Provides methods for interacting with a database table. From 18a10d2c915c5f533408b59515effe5057953c0a Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 21:50:45 +0100 Subject: [PATCH 60/76] feature(database): Added partial searching to MongoDB DocumentManager --- .../database/builder/MongoDbQueryBuilder.ts | 101 ++++++++++++++++++ .../MongoDbDocumentManager.ts | 19 +++- 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/core/domains/database/builder/MongoDbQueryBuilder.ts diff --git a/src/core/domains/database/builder/MongoDbQueryBuilder.ts b/src/core/domains/database/builder/MongoDbQueryBuilder.ts new file mode 100644 index 000000000..d04fc4c1e --- /dev/null +++ b/src/core/domains/database/builder/MongoDbQueryBuilder.ts @@ -0,0 +1,101 @@ +/** + * Order array type + */ +export type OrderArray = Record[] + +/** + * Options for select query + */ +export type SelectOptions = { + + /** + * Filter for query + */ + filter?: object; + + /** + * Allow partial search + */ + allowPartialSearch?: boolean + + /** + * Use fuzzy search + */ + useFuzzySearch?: boolean + +} + +class MongoDbQueryBuilder { + + /** + * Build select query + * @param options Select options + * @returns Query filter object + */ + select({ filter = {}, allowPartialSearch = false, useFuzzySearch = false }: SelectOptions): object { + + for(const key in filter) { + const value = filter[key] + + if(typeof value !== 'string') { + continue; + } + + if(allowPartialSearch && value.startsWith('%') || value.endsWith('%')) { + + if(useFuzzySearch) { + filter[key] = { $text: { $search: value } } + continue; + } + + const pattern = this.buildSelectRegexPattern(value) + filter[key] = { $regex: pattern } + } + } + + return filter + } + + /** + * Builds the regex pattern for partial searches + * @param value + * @returns + */ + buildSelectRegexPattern = (value: string): string => { + const valueWithoutPercentSign = this.stripPercentSigns(value) + let regex = valueWithoutPercentSign + + if(value.startsWith('%')) { + regex = '.*' + regex; + } + + if(value.endsWith('%')) { + regex = regex + '.*' + } + + return regex + } + + /** + * Strips the percent signs from the start and end of a string + * @param value The string to strip + * @returns The stripped string + */ + stripPercentSigns(value: string): string { + if(value.startsWith('%')) { + return value.substring(1, value.length - 1) + } + + if(value.endsWith('%')) { + return value.substring(0, value.length - 1) + } + + return value + } + +} + +/** + * Default export + */ +export default MongoDbQueryBuilder diff --git a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts index d7f4ff47f..60c9020b4 100644 --- a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts +++ b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts @@ -6,10 +6,14 @@ import MongoDB from "@src/core/domains/database/providers-db/MongoDB"; import MongoDBBelongsTo from "@src/core/domains/database/relationships/mongodb/MongoDBBelongsTo"; import { BulkWriteOptions, ObjectId, Sort, UpdateOptions } from "mongodb"; +import MongoDbQueryBuilder from "../builder/MongoDbQueryBuilder"; + class MongoDbDocumentManager extends BaseDocumentManager { protected driver!: MongoDB; + protected builder = new MongoDbQueryBuilder() + constructor(driver: MongoDB) { super(driver); this.driver = driver; @@ -102,8 +106,11 @@ class MongoDbDocumentManager extends BaseDocumentManager({ filter = {} }: { filter?: object }): Promise { + async findOne({ filter = {}, allowPartialSearch = false, useFuzzySearch = false }: Pick): Promise { return this.captureError(async() => { + + filter = this.builder.select({ filter, allowPartialSearch, useFuzzySearch }) + let document = await this.driver.getDb().collection(this.getTable()).findOne(filter) as T | null; if (document) { @@ -117,11 +124,15 @@ class MongoDbDocumentManager extends BaseDocumentManager({ filter, order, limit, skip }: FindOptions): Promise { + + async findMany({ filter, order, limit, skip, allowPartialSearch = false, useFuzzySearch = false }: FindOptions): Promise { return this.captureError(async() => { + + filter = this.builder.select({ filter, allowPartialSearch, useFuzzySearch }) + const documents = await this.driver .getDb() .collection(this.getTable()) From 5e8f856996bf88140a5e7a867937d3279d33b1c7 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 6 Oct 2024 21:51:02 +0100 Subject: [PATCH 61/76] test(database): Added db partial searching test --- src/tests/database/dbPartialSearch.test.ts | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/tests/database/dbPartialSearch.test.ts diff --git a/src/tests/database/dbPartialSearch.test.ts b/src/tests/database/dbPartialSearch.test.ts new file mode 100644 index 000000000..8605d267c --- /dev/null +++ b/src/tests/database/dbPartialSearch.test.ts @@ -0,0 +1,96 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Kernel from '@src/core/Kernel'; +import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import { DataTypes } from 'sequelize'; + +const connections = getTestConnectionNames() + +const createTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + schema.createTable('tests', { + name: DataTypes.STRING, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) +} + +const dropTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + if(await schema.tableExists('tests')) { + await schema.dropTable('tests'); + } +} + + +describe('test partial search', () => { + + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + new TestDatabaseProvider() + ] + }, {}) + + + for(const connectionName of connections) { + await dropTable(connectionName) + await createTable(connectionName) + } + }) + + test('test', async () => { + + for(const connectionName of connections) { + const documentManager = App.container('db').documentManager(connectionName).table('tests') as IDocumentManager + + console.log('connectionName', connectionName) + + const recordOneData = { + name: 'Test One', + createdAt: new Date(), + updatedAt: new Date() + } + const recordTwoData = { + name: 'Test Two', + createdAt: new Date(), + updatedAt: new Date() + } + + await documentManager.insertOne(recordOneData) + await documentManager.insertOne(recordTwoData) + + const recordOne = await documentManager.findOne({ filter: { name: 'Test One'} }) + const recordTwo = await documentManager.findOne({ filter: { name: 'Test Two'} }) + + console.log('Created two records', recordOne, recordTwo) + + expect(recordOne.id).toBeTruthy() + expect(recordTwo.id).toBeTruthy() + + const recordBothPartial = await documentManager.findMany({ filter: { name: '%Test%' }, allowPartialSearch: true }) + expect(recordBothPartial.length).toEqual(2) + + console.log('recordBothPartial', recordBothPartial) + + const recordOnePartial = await documentManager.findOne({ filter: { name: '%One' }, allowPartialSearch: true }) + expect(recordOnePartial?.id === recordOne.id).toBeTruthy() + + console.log('recordOnePartial', recordOnePartial) + + const recordTwoPartial = await documentManager.findOne({ filter: { name: '%Two' }, allowPartialSearch: true }) + expect(recordTwoPartial?.id === recordTwo.id).toBeTruthy() + + console.log('recordTwoPartial', recordTwoPartial) + } + + + }) +}); \ No newline at end of file From 9224713d710e01875cb3d863fcd1eea63e4deeb1 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 7 Oct 2024 08:42:47 +0100 Subject: [PATCH 62/76] feature(express): Added ability to apply request query filters for resource all --- .../MongoDbDocumentManager.ts | 3 +- .../interfaces/IRouteResourceOptions.ts | 8 ++- .../domains/express/services/QueryFilters.ts | 55 +++++++++++++++++++ .../services/Resources/BaseResourceService.ts | 20 +++++++ .../services/Resources/ResourceAllService.ts | 13 ++++- .../services/Resources/ResourceShowService.ts | 19 ++----- 6 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 src/core/domains/express/services/QueryFilters.ts diff --git a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts index 60c9020b4..ec5b1dbba 100644 --- a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts +++ b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts @@ -1,4 +1,5 @@ import BaseDocumentManager from "@src/core/domains/database/base/BaseDocumentManager"; +import MongoDbQueryBuilder from "@src/core/domains/database/builder/MongoDbQueryBuilder"; import InvalidObjectId from "@src/core/domains/database/exceptions/InvalidObjectId"; import { FindOptions, IDatabaseDocument, OrderOptions } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IBelongsToOptions } from "@src/core/domains/database/interfaces/relationships/IBelongsTo"; @@ -6,8 +7,6 @@ import MongoDB from "@src/core/domains/database/providers-db/MongoDB"; import MongoDBBelongsTo from "@src/core/domains/database/relationships/mongodb/MongoDBBelongsTo"; import { BulkWriteOptions, ObjectId, Sort, UpdateOptions } from "mongodb"; -import MongoDbQueryBuilder from "../builder/MongoDbQueryBuilder"; - class MongoDbDocumentManager extends BaseDocumentManager { protected driver!: MongoDB; diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 5c6166273..fafbf07c3 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -6,6 +6,11 @@ import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; export type ResourceType = 'all' | 'create' | 'update' | 'show' | 'destroy'; +export type SearchOptions = { + fields: string[]; + useFuzzySearch?: boolean; // Only applies to MongoDB provider +} + export interface IRouteResourceOptions extends Pick { path: string; resource: ModelConstructor; @@ -21,5 +26,6 @@ export interface IRouteResourceOptions extends Pick { paginate?: { pageSize: number; allowPageSizeOverride?: boolean; - } + }, + searching?: SearchOptions } \ No newline at end of file diff --git a/src/core/domains/express/services/QueryFilters.ts b/src/core/domains/express/services/QueryFilters.ts new file mode 100644 index 000000000..54cd07aa1 --- /dev/null +++ b/src/core/domains/express/services/QueryFilters.ts @@ -0,0 +1,55 @@ +import Singleton from "@src/core/base/Singleton"; +import { SearchOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { Request } from "express"; + +class QueryFilters extends Singleton { + + protected filters: object | undefined = undefined + + + /** + * Parses the request object to extract the filters from the query string + * + * @param {Request} req - The Express Request object + * @returns {this} - The QueryFilters class itself to enable chaining + */ + parseRequest(req: Request, options: SearchOptions = {} as SearchOptions): this { + try { + const { fields = [] } = options; + const decodedQuery = decodeURIComponent(req.query?.filters as string ?? ''); + const filtersParsed: object = JSON.parse(decodedQuery ?? '{}'); + let filters: object = {}; + + fields.forEach((field: string) => { + if (field in filtersParsed) { + filters = { + ...filters, + [field]: filtersParsed[field] + } + } + }) + + this.filters = filters + } + + catch (err) { + console.error(err) + } + + return this; + } + + + /** + * Returns the parsed filters from the request query string. + * If no filters were found, returns the defaultValue. + * @param defaultValue - The default value to return if no filters were found. + * @returns The parsed filters or the defaultValue. + */ + getFilters(defaultValue: object | undefined = undefined): object | undefined { + return this.filters ?? defaultValue + } + +} + +export default QueryFilters \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/BaseResourceService.ts b/src/core/domains/express/services/Resources/BaseResourceService.ts index 0bb818d85..3d357658b 100644 --- a/src/core/domains/express/services/Resources/BaseResourceService.ts +++ b/src/core/domains/express/services/Resources/BaseResourceService.ts @@ -80,6 +80,26 @@ abstract class BaseResourceService implements IResourceService { return SecurityReader.findFromRouteResourceOptions(options as IRouteResourceOptions, SecurityIdentifiers.RESOURCE_OWNER, [this.routeResourceType]); } + + /** + * Returns a new object with the same key-value pairs as the given object, but + * with an additional key-value pair for each key, where the key is wrapped in + * percent signs (e.g. "foo" becomes "%foo%"). This is useful for building + * filters in MongoDB queries. + * @param {object} filters - The object to transform + * @returns {object} - The transformed object + */ + filtersWithPercentSigns(filters: object): object { + return { + ...filters, + ...Object.keys(filters).reduce((acc, curr) => { + const value = filters[curr]; + acc[curr] = `%${value}%`; + return acc; + }, {}) + } + } + } export default BaseResourceService \ No newline at end of file diff --git a/src/core/domains/express/services/Resources/ResourceAllService.ts b/src/core/domains/express/services/Resources/ResourceAllService.ts index dbe8365a5..7352bc614 100644 --- a/src/core/domains/express/services/Resources/ResourceAllService.ts +++ b/src/core/domains/express/services/Resources/ResourceAllService.ts @@ -4,6 +4,7 @@ import { IPageOptions } from "@src/core/domains/express/interfaces/IResourceServ import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { RouteResourceTypes } from "@src/core/domains/express/routing/RouteResource"; import Paginate from "@src/core/domains/express/services/Paginate"; +import QueryFilters from "@src/core/domains/express/services/QueryFilters"; import BaseResourceService from "@src/core/domains/express/services/Resources/BaseResourceService"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import stripGuardedResourceProperties from "@src/core/domains/express/utils/stripGuardedResourceProperties"; @@ -37,7 +38,7 @@ class ResourceAllService extends BaseResourceService { // Build the page options, filters const pageOptions = this.buildPageOptions(req, options); - let filters = this.buildFilters(options); + let filters = this.buildFilters(req, options); // Check if the resource owner security applies to this route and it is valid // If it is valid, we add the owner's id to the filters @@ -83,6 +84,7 @@ class ResourceAllService extends BaseResourceService { filter: filters, limit: pageOptions.pageSize, skip: pageOptions.skip, + useFuzzySearch: options.searching?.useFuzzySearch, }) } @@ -92,8 +94,13 @@ class ResourceAllService extends BaseResourceService { * @param {IRouteResourceOptions} options - The options object * @returns {object} - The filters object */ - buildFilters(options: IRouteResourceOptions): object { - return options.allFilters ?? {}; + buildFilters(req: BaseRequest, options: IRouteResourceOptions): object { + const baseFilters = options.allFilters ?? {}; + + return this.filtersWithPercentSigns({ + ...baseFilters, + ...(new QueryFilters).parseRequest(req, options?.searching).getFilters() + }) } /** diff --git a/src/core/domains/express/services/Resources/ResourceShowService.ts b/src/core/domains/express/services/Resources/ResourceShowService.ts index 6fe62dff8..e132e59a5 100644 --- a/src/core/domains/express/services/Resources/ResourceShowService.ts +++ b/src/core/domains/express/services/Resources/ResourceShowService.ts @@ -11,6 +11,7 @@ import { App } from "@src/core/services/App"; import { Response } from "express"; + class ResourceShowService extends BaseResourceService { routeResourceType: string = RouteResourceTypes.SHOW @@ -33,9 +34,9 @@ class ResourceShowService extends BaseResourceService { if(!this.validateAuthorization(req, options)) { throw new UnauthorizedError() } - - // Build the page options, filters - let filters = this.buildFilters(options); + + // Build the filters + let filters: object = {} // Check if the resource owner security applies to this route and it is valid // If it is valid, we add the owner's id to the filters @@ -86,17 +87,7 @@ class ResourceShowService extends BaseResourceService { filter: filters, }) } - - /** - * Builds the filters object - * - * @param {IRouteResourceOptions} options - The options object - * @returns {object} - The filters object - */ - buildFilters(options: IRouteResourceOptions): object { - return options.showFilters ?? {}; - } - + } export default ResourceShowService; \ No newline at end of file From 5eca7f17439b076af67ba7b327a764087ce75b5c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 7 Oct 2024 21:48:08 +0100 Subject: [PATCH 63/76] fix(database): Fixed unhandled non string type in Postgres buiilder --- src/core/domains/database/builder/PostgresQueryBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/domains/database/builder/PostgresQueryBuilder.ts b/src/core/domains/database/builder/PostgresQueryBuilder.ts index efccd3288..0420321be 100644 --- a/src/core/domains/database/builder/PostgresQueryBuilder.ts +++ b/src/core/domains/database/builder/PostgresQueryBuilder.ts @@ -112,7 +112,7 @@ class PostgresQueryBuilder { return `"${key}" IS NULL`; } - if(allowPartialSearch && value.startsWith('%') || value.endsWith('%')) { + if(allowPartialSearch && typeof value === 'string' && (value.startsWith('%') || value.endsWith('%'))) { return `"${key}" LIKE :${key}` } From 9fe635da2eccb12a6cb395e37087fa70c92dbb47 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 7 Oct 2024 21:50:40 +0100 Subject: [PATCH 64/76] refactor/fix(migrations): Allow configurable migration model, uses different migration table for tests --- .../migrations/factory/MigrationFactory.ts | 8 ++++--- .../migrations/interfaces/IMigrationConfig.ts | 3 +++ .../migrations/models/MigrationModel.ts | 8 +++---- .../repository/MigrationRepository.ts | 12 ---------- .../migrations/schema/createMongoDBSchema.ts | 6 ++--- .../migrations/schema/createPostgresSchema.ts | 6 ++--- .../migrations/services/MigrationService.ts | 23 ++++++++++++------- src/tests/endTests.test.ts | 2 ++ ...gration.ts => CreateTestTableMigration.ts} | 0 .../migration/models/TestMigrationModel.ts | 17 ++++++++++++++ .../providers/TestMigrationProvider.ts | 2 ++ 11 files changed, 54 insertions(+), 33 deletions(-) delete mode 100644 src/core/domains/migrations/repository/MigrationRepository.ts rename src/tests/migration/migrations/{TestMigration.ts => CreateTestTableMigration.ts} (100%) create mode 100644 src/tests/migration/models/TestMigrationModel.ts diff --git a/src/core/domains/migrations/factory/MigrationFactory.ts b/src/core/domains/migrations/factory/MigrationFactory.ts index 70eed3da1..503071c42 100644 --- a/src/core/domains/migrations/factory/MigrationFactory.ts +++ b/src/core/domains/migrations/factory/MigrationFactory.ts @@ -1,4 +1,6 @@ import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; +import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; + type Props = { name: string; @@ -14,13 +16,13 @@ class MigrationFactory { * @param param0 * @returns */ - create({ name, batch, checksum, appliedAt }: Props): MigrationModel { - return new MigrationModel({ + create({ name, batch, checksum, appliedAt }: Props, modelCtor: ModelConstructor = MigrationModel): IModel { + return new modelCtor({ name, batch, checksum, appliedAt - }) + }); } } diff --git a/src/core/domains/migrations/interfaces/IMigrationConfig.ts b/src/core/domains/migrations/interfaces/IMigrationConfig.ts index e3a591cd4..03ad3831e 100644 --- a/src/core/domains/migrations/interfaces/IMigrationConfig.ts +++ b/src/core/domains/migrations/interfaces/IMigrationConfig.ts @@ -1,5 +1,8 @@ +import { ModelConstructor } from "@src/core/interfaces/IModel"; + export interface IMigrationConfig { appMigrationsDir?: string; keepProcessAlive?: boolean; + modelCtor?: ModelConstructor; } \ No newline at end of file diff --git a/src/core/domains/migrations/models/MigrationModel.ts b/src/core/domains/migrations/models/MigrationModel.ts index 80ee9142a..b3441ad80 100644 --- a/src/core/domains/migrations/models/MigrationModel.ts +++ b/src/core/domains/migrations/models/MigrationModel.ts @@ -32,10 +32,10 @@ export interface MigrationModelData extends IModelData { */ class MigrationModel extends Model { - /** - * The name of the table in the database. - */ - table = 'migrations'; + constructor(data: MigrationModelData, tableName = 'migrations') { + super(data); + this.table = tableName + } /** * The fields that are dates. diff --git a/src/core/domains/migrations/repository/MigrationRepository.ts b/src/core/domains/migrations/repository/MigrationRepository.ts deleted file mode 100644 index 6e57dbc0e..000000000 --- a/src/core/domains/migrations/repository/MigrationRepository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Repository from "@src/core/base/Repository"; -import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; - -class MigrationRepository extends Repository { - - constructor() { - super(MigrationModel) - } - -} - -export default MigrationRepository \ No newline at end of file diff --git a/src/core/domains/migrations/schema/createMongoDBSchema.ts b/src/core/domains/migrations/schema/createMongoDBSchema.ts index 244c834b7..dedfea3ec 100644 --- a/src/core/domains/migrations/schema/createMongoDBSchema.ts +++ b/src/core/domains/migrations/schema/createMongoDBSchema.ts @@ -6,14 +6,14 @@ import { App } from "@src/core/services/App"; * * @returns {Promise} */ -const createMongoDBSchema = async () => { +const createMongoDBSchema = async (tableName: string = 'migrations') => { const db = App.container('db').provider().getDb(); - if ((await db.listCollections().toArray()).map(c => c.name).includes('migrations')) { + if ((await db.listCollections().toArray()).map(c => c.name).includes(tableName)) { return; } - await db.createCollection('migrations'); + await db.createCollection(tableName); } export default createMongoDBSchema \ No newline at end of file diff --git a/src/core/domains/migrations/schema/createPostgresSchema.ts b/src/core/domains/migrations/schema/createPostgresSchema.ts index 3cf01f2c5..19de73338 100644 --- a/src/core/domains/migrations/schema/createPostgresSchema.ts +++ b/src/core/domains/migrations/schema/createPostgresSchema.ts @@ -7,15 +7,15 @@ import { DataTypes } from "sequelize"; * * @returns {Promise} */ -const createPostgresSchema = async () => { +const createPostgresSchema = async (tableName: string = 'migrations') => { const sequelize = App.container('db').provider().getSequelize(); const queryInterface = sequelize.getQueryInterface(); - if ((await queryInterface.showAllTables())?.includes('migrations')) { + if ((await queryInterface.showAllTables())?.includes(tableName)) { return; } - await queryInterface.createTable('migrations', { + await queryInterface.createTable(tableName, { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, diff --git a/src/core/domains/migrations/services/MigrationService.ts b/src/core/domains/migrations/services/MigrationService.ts index 1adc3cad5..d0217463b 100644 --- a/src/core/domains/migrations/services/MigrationService.ts +++ b/src/core/domains/migrations/services/MigrationService.ts @@ -1,12 +1,15 @@ +import Repository from "@src/core/base/Repository"; import MigrationFactory from "@src/core/domains/migrations/factory/MigrationFactory"; import { IMigration } from "@src/core/domains/migrations/interfaces/IMigration"; import { IMigrationConfig } from "@src/core/domains/migrations/interfaces/IMigrationConfig"; import { IMigrationService, IMigrationServiceOptions } from "@src/core/domains/migrations/interfaces/IMigrationService"; -import MigrationRepository from "@src/core/domains/migrations/repository/MigrationRepository"; +import MigrationModel from "@src/core/domains/migrations/models/MigrationModel"; import createMongoDBSchema from "@src/core/domains/migrations/schema/createMongoDBSchema"; import createPostgresSchema from "@src/core/domains/migrations/schema/createPostgresSchema"; import MigrationFileService from "@src/core/domains/migrations/services/MigrationFilesService"; import FileNotFoundError from "@src/core/exceptions/FileNotFoundError"; +import { ModelConstructor } from "@src/core/interfaces/IModel"; +import { IRepository } from "@src/core/interfaces/IRepository"; import { App } from "@src/core/services/App"; interface MigrationDetail { @@ -21,16 +24,19 @@ interface MigrationDetail { */ class MigrationService implements IMigrationService { - private fileService!: MigrationFileService; + private readonly fileService!: MigrationFileService; - private repository!: MigrationRepository; + private readonly repository!: IRepository; protected config!: IMigrationConfig; + protected modelCtor!: ModelConstructor; + constructor(config: IMigrationConfig = {}) { this.config = config; this.fileService = new MigrationFileService(config.appMigrationsDir); - this.repository = new MigrationRepository(); + this.modelCtor = config.modelCtor ?? MigrationModel; + this.repository = new Repository(this.modelCtor); } async boot() { @@ -191,7 +197,7 @@ class MigrationService implements IMigrationService { batch: newBatchCount, checksum: fileChecksum, appliedAt: new Date(), - }) + }, this.modelCtor) await model.save(); } @@ -218,7 +224,7 @@ class MigrationService implements IMigrationService { * @returns */ protected async getMigrationResults(filters?: object) { - return await (new MigrationRepository).findMany({ + return await this.repository.findMany({ ...(filters ?? {}) }) } @@ -229,19 +235,20 @@ class MigrationService implements IMigrationService { */ protected async createSchema(): Promise { try { + const tableName = (new this.modelCtor).table /** * Handle MongoDB driver */ if (App.container('db').isProvider('mongodb')) { - await createMongoDBSchema(); + await createMongoDBSchema(tableName); } /** * Handle Postgres driver */ if (App.container('db').isProvider('postgres')) { - await createPostgresSchema(); + await createPostgresSchema(tableName); } } catch (err) { diff --git a/src/tests/endTests.test.ts b/src/tests/endTests.test.ts index a446da09e..bb3d8f55e 100644 --- a/src/tests/endTests.test.ts +++ b/src/tests/endTests.test.ts @@ -4,6 +4,7 @@ import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; import testAppConfig from '@src/tests/config/testConfig'; import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestMigrationModel from '@src/tests/migration/models/TestMigrationModel'; import { TestAuthorModel } from '@src/tests/models/models/TestAuthor'; import TestModel from '@src/tests/models/models/TestModel'; import { TestMovieModel } from '@src/tests/models/models/TestMovie'; @@ -28,6 +29,7 @@ describe('clean up tables', () => { (new TestAuthorModel(null)).table, (new TestWorkerModel(null)).table, (new TestModel(null)).table, + (new TestMigrationModel).table ].filter((value, index, self) => self.indexOf(value) === index); for (const connectionName of getTestConnectionNames()) { diff --git a/src/tests/migration/migrations/TestMigration.ts b/src/tests/migration/migrations/CreateTestTableMigration.ts similarity index 100% rename from src/tests/migration/migrations/TestMigration.ts rename to src/tests/migration/migrations/CreateTestTableMigration.ts diff --git a/src/tests/migration/models/TestMigrationModel.ts b/src/tests/migration/models/TestMigrationModel.ts new file mode 100644 index 000000000..d85d43037 --- /dev/null +++ b/src/tests/migration/models/TestMigrationModel.ts @@ -0,0 +1,17 @@ +import MigrationModel, { MigrationModelData } from "@src/core/domains/migrations/models/MigrationModel"; + +/** + * Model for test migrations stored in the database. + */ +class TestMigrationModel extends MigrationModel { + + constructor(data: MigrationModelData) { + super(data, 'testMigrations'); + } + +} + +/** + * The default migration model. + */ +export default TestMigrationModel diff --git a/src/tests/migration/providers/TestMigrationProvider.ts b/src/tests/migration/providers/TestMigrationProvider.ts index 7c2c59595..0606ecb24 100644 --- a/src/tests/migration/providers/TestMigrationProvider.ts +++ b/src/tests/migration/providers/TestMigrationProvider.ts @@ -3,6 +3,7 @@ import MigrateUpCommand from "@src/core/domains/migrations/commands/MigrateUpCom import { IMigrationConfig } from "@src/core/domains/migrations/interfaces/IMigrationConfig"; import MigrationProvider from "@src/core/domains/migrations/providers/MigrationProvider"; import { App } from "@src/core/services/App"; +import TestMigrationModel from "@src/tests/migration/models/TestMigrationModel"; class TestMigrationProvider extends MigrationProvider { @@ -17,6 +18,7 @@ class TestMigrationProvider extends MigrationProvider { const config: IMigrationConfig = { keepProcessAlive: true, appMigrationsDir: '@src/../src/tests/migration/migrations', + modelCtor: TestMigrationModel } App.container('console').register().addCommandConfig([ From 5a5fce9421b2efd4b9d9188086da4af19663bb04 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 7 Oct 2024 21:53:47 +0100 Subject: [PATCH 65/76] fix(migrations): Missing model constructor parameter --- src/core/domains/migrations/models/MigrationModel.ts | 2 +- src/tests/endTests.test.ts | 2 +- src/tests/migration/models/TestMigrationModel.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/domains/migrations/models/MigrationModel.ts b/src/core/domains/migrations/models/MigrationModel.ts index b3441ad80..f12bf3621 100644 --- a/src/core/domains/migrations/models/MigrationModel.ts +++ b/src/core/domains/migrations/models/MigrationModel.ts @@ -32,7 +32,7 @@ export interface MigrationModelData extends IModelData { */ class MigrationModel extends Model { - constructor(data: MigrationModelData, tableName = 'migrations') { + constructor(data: MigrationModelData | null, tableName = 'migrations') { super(data); this.table = tableName } diff --git a/src/tests/endTests.test.ts b/src/tests/endTests.test.ts index bb3d8f55e..5951e7c34 100644 --- a/src/tests/endTests.test.ts +++ b/src/tests/endTests.test.ts @@ -29,7 +29,7 @@ describe('clean up tables', () => { (new TestAuthorModel(null)).table, (new TestWorkerModel(null)).table, (new TestModel(null)).table, - (new TestMigrationModel).table + (new TestMigrationModel(null)).table ].filter((value, index, self) => self.indexOf(value) === index); for (const connectionName of getTestConnectionNames()) { diff --git a/src/tests/migration/models/TestMigrationModel.ts b/src/tests/migration/models/TestMigrationModel.ts index d85d43037..47881043f 100644 --- a/src/tests/migration/models/TestMigrationModel.ts +++ b/src/tests/migration/models/TestMigrationModel.ts @@ -5,7 +5,7 @@ import MigrationModel, { MigrationModelData } from "@src/core/domains/migrations */ class TestMigrationModel extends MigrationModel { - constructor(data: MigrationModelData) { + constructor(data: MigrationModelData | null) { super(data, 'testMigrations'); } From b3e2197631f677758ef74785e092784cfdf13b3b Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 01:27:38 +0100 Subject: [PATCH 66/76] feat(logger): Added loggerService using winston logger --- .../logger/interfaces/ILoggerService.ts | 13 ++++ .../logger/providers/LoggerProvider.ts | 23 ++++++ .../domains/logger/services/LoggerService.ts | 77 +++++++++++++++++++ src/core/interfaces/ICoreContainers.ts | 8 ++ src/core/providers/CoreProviders.ts | 11 +++ 5 files changed, 132 insertions(+) create mode 100644 src/core/domains/logger/interfaces/ILoggerService.ts create mode 100644 src/core/domains/logger/providers/LoggerProvider.ts create mode 100644 src/core/domains/logger/services/LoggerService.ts diff --git a/src/core/domains/logger/interfaces/ILoggerService.ts b/src/core/domains/logger/interfaces/ILoggerService.ts new file mode 100644 index 000000000..a657b4fd1 --- /dev/null +++ b/src/core/domains/logger/interfaces/ILoggerService.ts @@ -0,0 +1,13 @@ +/* eslint-disable no-unused-vars */ +import winston from "winston"; + +export interface ILoggerService +{ + getLogger(): winston.Logger; + + info(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; + debug(...args: any[]): void; + verbose(...args: any[]): void; +} \ No newline at end of file diff --git a/src/core/domains/logger/providers/LoggerProvider.ts b/src/core/domains/logger/providers/LoggerProvider.ts new file mode 100644 index 000000000..97824b1a8 --- /dev/null +++ b/src/core/domains/logger/providers/LoggerProvider.ts @@ -0,0 +1,23 @@ +import BaseProvider from "@src/core/base/Provider"; +import { App } from "@src/core/services/App"; + +import LoggerService from "../services/LoggerService"; + +class LoggerProvider extends BaseProvider { + + async register(): Promise { + + const loggerService = new LoggerService(); + + // We will boot the logger here to provide it early for other providers + loggerService.boot(); + + App.setContainer('logger', loggerService); + + } + + async boot(): Promise {} + +} + +export default LoggerProvider \ No newline at end of file diff --git a/src/core/domains/logger/services/LoggerService.ts b/src/core/domains/logger/services/LoggerService.ts new file mode 100644 index 000000000..7f265c1ac --- /dev/null +++ b/src/core/domains/logger/services/LoggerService.ts @@ -0,0 +1,77 @@ +import { EnvironmentProduction } from "@src/core/consts/Environment"; +import { App } from "@src/core/services/App"; +import path from "path"; +import winston from "winston"; + +import { ILoggerService } from "../interfaces/ILoggerService"; + +class LoggerService implements ILoggerService { + + /** + * Winston logger instance + */ + protected logger!: winston.Logger + + /** + * Bootstraps the winston logger instance. + * @returns {Promise} + */ + boot() { + if(this.logger) { + return; + } + + const logger = winston.createLogger({ + level:'info', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: path.resolve('@src/../', 'storage/logs/larascript.log') }) + ] + }) + + if(App.env() !== EnvironmentProduction) { + logger.add(new winston.transports.Console({ format: winston.format.simple() })); + } + + this.logger = logger + } + + /** + * Returns the underlying winston logger instance. + * @returns {winston.Logger} + */ + getLogger() { + return this.logger + } + + error(...args: any[]) { + this.logger.error([...args]) + } + + help(...args: any[]) { + this.logger.help([...args]) + } + + data(...args: any[]) { + this.logger.data([...args]) + } + + info(...args: any[]) { + this.logger.info([...args]) + } + + warn(...args: any[]) { + this.logger.warn([...args]) + } + + debug(...args: any[]) { + this.logger.debug([...args]) + } + + verbose(...args: any[]) { + this.logger.verbose([...args]) + } + +} + +export default LoggerService \ No newline at end of file diff --git a/src/core/interfaces/ICoreContainers.ts b/src/core/interfaces/ICoreContainers.ts index f7d691fd4..a2106b1c1 100644 --- a/src/core/interfaces/ICoreContainers.ts +++ b/src/core/interfaces/ICoreContainers.ts @@ -7,6 +7,8 @@ import IExpressService from '@src/core/domains/express/interfaces/IExpressServic import IValidatorService from '@src/core/domains/validator/interfaces/IValidatorService'; import readline from 'node:readline'; +import { ILoggerService } from '../domains/logger/interfaces/ILoggerService'; + export interface ICoreContainers { [key: string]: any; @@ -57,4 +59,10 @@ export interface ICoreContainers { * Provided by '@src/core/domains/validator/providers/ValidatorProvider' */ validate: IValidatorService; + + /** + * Logger service + * Provided by '@src/core/domains/logger/providers/LoggerProvider' + */ + logger: ILoggerService; } diff --git a/src/core/providers/CoreProviders.ts b/src/core/providers/CoreProviders.ts index 56926e1ea..64ccfb7cf 100644 --- a/src/core/providers/CoreProviders.ts +++ b/src/core/providers/CoreProviders.ts @@ -9,6 +9,8 @@ import SetupProvider from "@src/core/domains/setup/providers/SetupProvider"; import ValidatorProvider from "@src/core/domains/validator/providers/ValidatorProvider"; import { IProvider } from "@src/core/interfaces/IProvider"; +import LoggerProvider from "../domains/logger/providers/LoggerProvider"; + /** * Core providers for the framework * @@ -18,6 +20,13 @@ import { IProvider } from "@src/core/interfaces/IProvider"; */ const CoreProviders: IProvider[] = [ + /** + * Logger provider + * + * Provides logging services by utilising winston + */ + new LoggerProvider(), + /** * Console provider * @@ -80,6 +89,8 @@ const CoreProviders: IProvider[] = [ * Provides setup commands and helpers */ new SetupProvider(), + + ]; export default CoreProviders \ No newline at end of file From cc42b77d58ef905fd3d5370c9f554fb55a12aa08 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 01:37:15 +0100 Subject: [PATCH 67/76] refactor(logger): Updated console methods to use new logger service --- package.json | 1 + src/app.ts | 7 +- src/app/commands/ExampleCommand.ts | 2 +- src/app/events/listeners/ExampleListener.ts | 5 +- src/core/actions/health.ts | 1 - src/core/base/Provider.ts | 8 +- .../domains/console/commands/WorkerCommand.ts | 4 +- .../database/base/BaseDocumentManager.ts | 3 +- .../events/services/EventDispatcher.ts | 6 +- src/core/domains/events/services/Worker.ts | 20 +- .../middleware/basicLoggerMiddleware.ts | 3 +- .../requestContextLoggerMiddleware.ts | 4 +- .../domains/express/requests/responseError.ts | 3 +- .../express/services/ExpressService.ts | 2 +- .../domains/express/services/QueryFilters.ts | 3 +- .../express/services/RequestContextCleaner.ts | 1 - .../logger/interfaces/ILoggerService.ts | 1 + .../logger/providers/LoggerProvider.ts | 3 +- .../domains/logger/services/LoggerService.ts | 47 ++++- .../domains/make/base/BaseMakeFileCommand.ts | 3 +- .../domains/make/providers/MakeProvider.ts | 2 +- .../make/templates/Listener.ts.template | 2 +- .../migrations/services/MigrationService.ts | 18 +- .../domains/setup/utils/defaultCredentials.ts | 5 +- src/core/interfaces/ICoreContainers.ts | 3 +- src/core/providers/CoreProviders.ts | 3 +- src/core/services/PackageJsonService.ts | 5 +- src/tests/auth/auth.test.ts | 4 +- src/tests/database/dbPartialSearch.test.ts | 10 +- src/tests/database/dbProviders.test.ts | 20 +- src/tests/events/listeners/TestListener.ts | 3 +- .../events/listeners/TestQueueListener.ts | 3 +- src/tests/models/modelBelongsTo.test.ts | 3 +- src/tests/models/modelCrud.test.ts | 2 +- src/tests/models/modelHasMany.test.ts | 2 +- src/tests/validator/validator.test.ts | 2 +- src/tinker.ts | 3 +- yarn.lock | 186 +++++++++++++++++- 38 files changed, 313 insertions(+), 90 deletions(-) diff --git a/package.json b/package.json index 37192da01..bf2929cb4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "sequelize": "^6.37.3", "sqlite3": "^5.1.7", "uuid": "^10.0.0", + "winston": "^3.15.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index aaa78f98d..4640c91fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import appConfig from '@src/config/app'; import CommandNotFoundException from '@src/core/domains/console/exceptions/CommandNotFoundException'; import CommandBootService from '@src/core/domains/console/service/CommandBootService'; import Kernel, { KernelOptions } from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; (async () => { try { @@ -16,14 +17,14 @@ import Kernel, { KernelOptions } from '@src/core/Kernel'; */ await Kernel.boot(appConfig, options); - console.log('[App]: Started'); + App.container('logger').info('[App]: Started'); /** * Execute commands */ await cmdBoot.boot(args); - } + } catch (err) { // We can safetly ignore CommandNotFoundExceptions @@ -31,7 +32,7 @@ import Kernel, { KernelOptions } from '@src/core/Kernel'; return; } - console.error('[App]: Failed to start', err); + App.container('logger').error('[App]: Failed to start', err); throw err; } })(); \ No newline at end of file diff --git a/src/app/commands/ExampleCommand.ts b/src/app/commands/ExampleCommand.ts index bd66de7bd..286794b58 100644 --- a/src/app/commands/ExampleCommand.ts +++ b/src/app/commands/ExampleCommand.ts @@ -5,7 +5,7 @@ export default class ExampleCommand extends BaseCommand { signature: string = 'app:example'; async execute() { - console.log('Hello world!') + // Handle the logic } } \ No newline at end of file diff --git a/src/app/events/listeners/ExampleListener.ts b/src/app/events/listeners/ExampleListener.ts index 170aebee1..5f166aa43 100644 --- a/src/app/events/listeners/ExampleListener.ts +++ b/src/app/events/listeners/ExampleListener.ts @@ -2,8 +2,9 @@ import EventListener from "@src/core/domains/events/services/EventListener"; export class ExampleListener extends EventListener<{userId: string}> { + // eslint-disable-next-line no-unused-vars handle = async (payload: { userId: string}) => { - console.log('[ExampleListener]', payload.userId) + // Handle the logic } - + } \ No newline at end of file diff --git a/src/core/actions/health.ts b/src/core/actions/health.ts index 306b6308a..23f1e3af1 100644 --- a/src/core/actions/health.ts +++ b/src/core/actions/health.ts @@ -33,7 +33,6 @@ export default async (req: Request, res: Response) => { } } catch (error) { - console.error(error) // If there is an error, send the error response responseError(req, res, error as Error) return; diff --git a/src/core/base/Provider.ts b/src/core/base/Provider.ts index 0043a73f2..d2ea88320 100644 --- a/src/core/base/Provider.ts +++ b/src/core/base/Provider.ts @@ -1,4 +1,5 @@ import { IProvider } from "@src/core/interfaces/IProvider"; +import { App } from "@src/core/services/App"; /** * Base class for providers @@ -45,12 +46,7 @@ export default abstract class BaseProvider implements IProvider { * @param {...any[]} args - Additional arguments to log */ protected log(message: string, ...args: any[]): void { - const str = `[Provider] ${message}`; - if(args.length > 0) { - console.log(str, ...args); - return; - } - console.log(`[Provider] ${message}`); + App.container('logger').info(message, ...args); } /** diff --git a/src/core/domains/console/commands/WorkerCommand.ts b/src/core/domains/console/commands/WorkerCommand.ts index 992832b23..febf1b1d7 100644 --- a/src/core/domains/console/commands/WorkerCommand.ts +++ b/src/core/domains/console/commands/WorkerCommand.ts @@ -25,7 +25,7 @@ export default class WorkerCommand extends BaseCommand { const worker = Worker.getInstance() worker.setDriver(driver) - console.log('Running worker...', worker.options) + App.container('logger').console('Running worker...', worker.options) await worker.work(); @@ -35,7 +35,7 @@ export default class WorkerCommand extends BaseCommand { setInterval(async () => { await worker.work() - console.log('Running worker again in ' + worker.options.runAfterSeconds.toString() + ' seconds') + App.container('logger').console('Running worker again in ' + worker.options.runAfterSeconds.toString() + ' seconds') }, worker.options.runAfterSeconds * 1000) } diff --git a/src/core/domains/database/base/BaseDocumentManager.ts b/src/core/domains/database/base/BaseDocumentManager.ts index 9ddda0f00..68f4a66c1 100644 --- a/src/core/domains/database/base/BaseDocumentManager.ts +++ b/src/core/domains/database/base/BaseDocumentManager.ts @@ -10,6 +10,7 @@ import { IHasManyOptions } from "@src/core/domains/database/interfaces/relations import BelongsTo from "@src/core/domains/database/relationships/BelongsTo"; import HasMany from "@src/core/domains/database/relationships/HasMany"; import DocumentValidator from "@src/core/domains/database/validator/DocumentValidator"; +import { App } from "@src/core/services/App"; /** * Abstract base class for document management operations @@ -131,7 +132,7 @@ abstract class BaseDocumentManager(event: IEvent) { - console.log(`[EventDispatcher:dispatch] Event '${event.name}' with driver '${event.driver}'`) + App.container('logger').info(`[EventDispatcher:dispatch] Event '${event.name}' with driver '${event.driver}'`) const driverOptions = this.getDriverOptionsFromEvent(event) const driverCtor = driverOptions.driver @@ -36,5 +36,5 @@ export default class EventDispatcher extends Singleton implements IEventDispatch return driver } - + } \ No newline at end of file diff --git a/src/core/domains/events/services/Worker.ts b/src/core/domains/events/services/Worker.ts index 017244a0e..0d792ae8c 100644 --- a/src/core/domains/events/services/Worker.ts +++ b/src/core/domains/events/services/Worker.ts @@ -33,7 +33,7 @@ export default class Worker extends Singleton { */ setDriver(driver: string) { this.options = this.getOptions(driver) - this.log(`Driver set to '${driver}'`,) + this.logToConsole(`Driver set to '${driver}'`,) } /** @@ -57,8 +57,8 @@ export default class Worker extends Singleton { // Fetch the current list of queued results const workerResults: WorkerModel[] = await worker.getWorkerResults(this.options.queueName) - this.log('collection: ' + new this.options.workerModelCtor().table) - this.log(`${workerResults.length} queued items with queue name '${this.options.queueName}'`) + this.logToConsole('collection: ' + new this.options.workerModelCtor().table) + this.logToConsole(`${workerResults.length} queued items with queue name '${this.options.queueName}'`) for(const workerModel of workerResults) { // We set the model here to pass it to the failedWorkerModel method, @@ -66,12 +66,12 @@ export default class Worker extends Singleton { model = workerModel try { - console.log('Worker processing model', model.getId()?.toString()) + App.container('logger').console('Worker processing model', model.getId()?.toString()) await worker.processWorkerModel(model) } catch (err) { if(!(err instanceof Error)) { - console.error(err) + App.container('logger').error(err) return; } @@ -124,7 +124,7 @@ export default class Worker extends Singleton { // Delete record as it was a success await model.delete(); - this.log(`Processed: ${eventName}`) + this.logToConsole(`Processed: ${eventName}`) } /** @@ -141,7 +141,7 @@ export default class Worker extends Singleton { const currentAttempt = (model.getAttribute('attempt') ?? 0) const nextCurrentAttempt = currentAttempt + 1 - this.log(`Failed ${model.getAttribute('eventName')} attempts ${currentAttempt + 1} out of ${retries}, ID: ${model.getId()?.toString()}`) + this.logToConsole(`Failed ${model.getAttribute('eventName')} attempts ${currentAttempt + 1} out of ${retries}, ID: ${model.getId()?.toString()}`) // If reached max, move to failed collection if(nextCurrentAttempt >= retries) { @@ -160,7 +160,7 @@ export default class Worker extends Singleton { * @param err */ async moveFailedWorkerModel(model: WorkerModel, err: Error) { - this.log('Moved to failed') + this.logToConsole('Moved to failed') const failedWorkerModel = (new FailedWorkerModelFactory).create( this.options.failedCollection, @@ -183,8 +183,8 @@ export default class Worker extends Singleton { * Logs a message to the console * @param message The message to log */ - protected log(message: string) { - console.log('[Worker]: ', message) + protected logToConsole(message: string) { + App.container('logger').console('[Worker]: ', message) } } diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts index 38d21c00f..a3ce78f5b 100644 --- a/src/core/domains/express/middleware/basicLoggerMiddleware.ts +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -1,8 +1,9 @@ import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { App } from '@src/core/services/App'; import { NextFunction, Response } from 'express'; export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('New request: ', `${req.method} ${req.url}`, 'Headers: ', req.headers); + App.container('logger').info('New request: ', `${req.method} ${req.url}`, 'Headers: ', req.headers); next(); }; diff --git a/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts index dc4342c87..e13c8de4d 100644 --- a/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts +++ b/src/core/domains/express/middleware/requestContextLoggerMiddleware.ts @@ -14,8 +14,8 @@ const requestContextLoggerMiddleware = () => (req: BaseRequest, res: Response, n } res.once('finish', () => { - console.log('requestContext: ', App.container('requestContext').getRequestContext()) - console.log('ipContext: ', App.container('requestContext').getIpContext()) + App.container('logger').info('requestContext: ', App.container('requestContext').getRequestContext()) + App.container('logger').info('ipContext: ', App.container('requestContext').getIpContext()) }) next() diff --git a/src/core/domains/express/requests/responseError.ts b/src/core/domains/express/requests/responseError.ts index dd0822886..ae7fc41c4 100644 --- a/src/core/domains/express/requests/responseError.ts +++ b/src/core/domains/express/requests/responseError.ts @@ -17,6 +17,7 @@ export default (req: Request , res: Response, err: Error, code: number = 500) => return; } - console.error(err) + App.container('logger').error(err) + res.status(code).send({ error: `${err.message}` }) } \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 332088b89..cf40544c4 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -252,7 +252,7 @@ export default class ExpressService extends Service implements I } } - console.log(str) + App.container('logger').info(str) } } diff --git a/src/core/domains/express/services/QueryFilters.ts b/src/core/domains/express/services/QueryFilters.ts index 54cd07aa1..ae9bd2f0b 100644 --- a/src/core/domains/express/services/QueryFilters.ts +++ b/src/core/domains/express/services/QueryFilters.ts @@ -1,5 +1,6 @@ import Singleton from "@src/core/base/Singleton"; import { SearchOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { App } from "@src/core/services/App"; import { Request } from "express"; class QueryFilters extends Singleton { @@ -33,7 +34,7 @@ class QueryFilters extends Singleton { } catch (err) { - console.error(err) + App.container('logger').error(err) } return this; diff --git a/src/core/domains/express/services/RequestContextCleaner.ts b/src/core/domains/express/services/RequestContextCleaner.ts index 529e2323a..fe255a744 100644 --- a/src/core/domains/express/services/RequestContextCleaner.ts +++ b/src/core/domains/express/services/RequestContextCleaner.ts @@ -80,7 +80,6 @@ class RequestContextCleaner extends Singleton { // If the context is empty, remove it from the store if(context.size === 0) { - console.log('Removed ipContext', ip) context.delete(ip) } } diff --git a/src/core/domains/logger/interfaces/ILoggerService.ts b/src/core/domains/logger/interfaces/ILoggerService.ts index a657b4fd1..5ccb2ff88 100644 --- a/src/core/domains/logger/interfaces/ILoggerService.ts +++ b/src/core/domains/logger/interfaces/ILoggerService.ts @@ -10,4 +10,5 @@ export interface ILoggerService error(...args: any[]): void; debug(...args: any[]): void; verbose(...args: any[]): void; + console(...args: any[]): void; } \ No newline at end of file diff --git a/src/core/domains/logger/providers/LoggerProvider.ts b/src/core/domains/logger/providers/LoggerProvider.ts index 97824b1a8..dfb656805 100644 --- a/src/core/domains/logger/providers/LoggerProvider.ts +++ b/src/core/domains/logger/providers/LoggerProvider.ts @@ -1,8 +1,7 @@ import BaseProvider from "@src/core/base/Provider"; +import LoggerService from "@src/core/domains/logger/services/LoggerService"; import { App } from "@src/core/services/App"; -import LoggerService from "../services/LoggerService"; - class LoggerProvider extends BaseProvider { async register(): Promise { diff --git a/src/core/domains/logger/services/LoggerService.ts b/src/core/domains/logger/services/LoggerService.ts index 7f265c1ac..380b0afe3 100644 --- a/src/core/domains/logger/services/LoggerService.ts +++ b/src/core/domains/logger/services/LoggerService.ts @@ -1,10 +1,9 @@ import { EnvironmentProduction } from "@src/core/consts/Environment"; +import { ILoggerService } from "@src/core/domains/logger/interfaces/ILoggerService"; import { App } from "@src/core/services/App"; import path from "path"; import winston from "winston"; -import { ILoggerService } from "../interfaces/ILoggerService"; - class LoggerService implements ILoggerService { /** @@ -44,34 +43,78 @@ class LoggerService implements ILoggerService { return this.logger } + /** + * Logs the given arguments to the console with the 'error' log level. + * @param {...any[]} args The arguments to log to the console. + */ error(...args: any[]) { this.logger.error([...args]) } + /** + * Logs the given arguments to the console with the 'help' log level. + * @param {...any[]} args The arguments to log to the console. + */ help(...args: any[]) { this.logger.help([...args]) } + /** + * Logs the given arguments to the console with the 'data' log level. + * @param {...any[]} args The arguments to log to the console. + */ data(...args: any[]) { this.logger.data([...args]) } + /** + * Logs the given arguments to the console with the 'info' log level. + * @param {...any[]} args The arguments to log to the console. + */ info(...args: any[]) { this.logger.info([...args]) } + /** + * Logs the given arguments to the console with the 'warn' log level. + * @param {...any[]} args The arguments to log to the console. + */ warn(...args: any[]) { this.logger.warn([...args]) } + /** + * Logs the given arguments to the console with the 'debug' log level. + * @param {...any[]} args The arguments to log to the console. + */ debug(...args: any[]) { this.logger.debug([...args]) } + /** + * Logs the given arguments to the console with the 'verbose' log level. + * @param {...any[]} args The arguments to log to the console. + */ verbose(...args: any[]) { this.logger.verbose([...args]) } + /** + * Outputs the given arguments directly to the console using the console transport. + * @param {...any[]} args The arguments to output to the console. + */ + console(...args: any[]) { + const logger = winston.createLogger({ + level:'info', + format: winston.format.json(), + transports: [ + new winston.transports.Console({ format: winston.format.simple() }) + ] + }) + + logger.info([...args]) + } + } export default LoggerService \ No newline at end of file diff --git a/src/core/domains/make/base/BaseMakeFileCommand.ts b/src/core/domains/make/base/BaseMakeFileCommand.ts index c79252acf..88947b5b1 100644 --- a/src/core/domains/make/base/BaseMakeFileCommand.ts +++ b/src/core/domains/make/base/BaseMakeFileCommand.ts @@ -4,6 +4,7 @@ import { IMakeFileArguments } from "@src/core/domains/make/interfaces/IMakeFileA import { IMakeOptions } from "@src/core/domains/make/interfaces/IMakeOptions"; import ArgumentObserver from "@src/core/domains/make/observers/ArgumentObserver"; import MakeFileService from "@src/core/domains/make/services/MakeFileService"; +import { App } from "@src/core/services/App"; import Str from "@src/core/util/str/Str"; const DefaultOptions: Partial = { @@ -108,7 +109,7 @@ export default class BaseMakeFileCommand extends BaseCommand { // Write the new file this.makeFileService.writeContent(template); - console.log(`Created ${this.options.makeType}: ` + this.makeFileService.getTargetDirFullPath()); + App.container('logger').info(`Created ${this.options.makeType}: ` + this.makeFileService.getTargetDirFullPath()); } /** diff --git a/src/core/domains/make/providers/MakeProvider.ts b/src/core/domains/make/providers/MakeProvider.ts index 1c074c7b4..b130041f3 100644 --- a/src/core/domains/make/providers/MakeProvider.ts +++ b/src/core/domains/make/providers/MakeProvider.ts @@ -18,7 +18,7 @@ import { App } from "@src/core/services/App"; export default class MakeProvider extends BaseProvider { async register(): Promise { - console.log('[Provider] Registering MakeProvider') + this.log('Registering MakeProvider') App.container('console').register().registerAll([ MakeCmdCommand, diff --git a/src/core/domains/make/templates/Listener.ts.template b/src/core/domains/make/templates/Listener.ts.template index 2f8d81525..3bc95d8e7 100644 --- a/src/core/domains/make/templates/Listener.ts.template +++ b/src/core/domains/make/templates/Listener.ts.template @@ -16,7 +16,7 @@ export class #name# extends EventListener { */ handle = async (payload: I#name#Data) => { - console.log('[TestListener]', payload); + // Handle the logic } diff --git a/src/core/domains/migrations/services/MigrationService.ts b/src/core/domains/migrations/services/MigrationService.ts index d0217463b..afc8e96a0 100644 --- a/src/core/domains/migrations/services/MigrationService.ts +++ b/src/core/domains/migrations/services/MigrationService.ts @@ -102,12 +102,12 @@ class MigrationService implements IMigrationService { const newBatchCount = (await this.getCurrentBatchCount()) + 1; if (!migrationsDetails.length) { - console.log('[Migration] No migrations to run'); + App.container('logger').info('[Migration] No migrations to run'); } // Run the migrations for every file for (const migrationDetail of migrationsDetails) { - console.log('[Migration] up -> ' + migrationDetail.fileName); + App.container('logger').info('[Migration] up -> ' + migrationDetail.fileName); await this.handleFileUp(migrationDetail, newBatchCount); } @@ -139,7 +139,7 @@ class MigrationService implements IMigrationService { }); if (!results.length) { - console.log('[Migration] No migrations to run'); + App.container('logger').info('[Migration] No migrations to run'); } // Run the migrations @@ -149,7 +149,7 @@ class MigrationService implements IMigrationService { const migration = await this.fileService.getImportMigrationClass(fileName); // Run the down method - console.log(`[Migration] down -> ${fileName}`); + App.container('logger').info(`[Migration] down -> ${fileName}`); await migration.down(); // Delete the migration document @@ -180,16 +180,16 @@ class MigrationService implements IMigrationService { }); if (migrationDocument) { - console.log(`[Migration] ${fileName} already applied`); + App.container('logger').info(`[Migration] ${fileName} already applied`); return; } if (!migration.shouldUp()) { - console.log(`[Migration] Skipping (Provider mismatch) -> ${fileName}`); + App.container('logger').info(`[Migration] Skipping (Provider mismatch) -> ${fileName}`); return; } - console.log(`[Migration] up -> ${fileName}`); + App.container('logger').info(`[Migration] up -> ${fileName}`); await migration.up(); const model = (new MigrationFactory).create({ @@ -252,10 +252,10 @@ class MigrationService implements IMigrationService { } } catch (err) { - console.log('[Migration] createSchema', err) + App.container('logger').info('[Migration] createSchema', err) if (err instanceof Error) { - console.error(err) + App.container('logger').error(err) } } } diff --git a/src/core/domains/setup/utils/defaultCredentials.ts b/src/core/domains/setup/utils/defaultCredentials.ts index b25b9d6fa..01cb96882 100644 --- a/src/core/domains/setup/utils/defaultCredentials.ts +++ b/src/core/domains/setup/utils/defaultCredentials.ts @@ -1,3 +1,4 @@ +import { App } from "@src/core/services/App" import fs from "fs" import path from "path" @@ -17,7 +18,7 @@ const extractDefaultMongoDBCredentials = () => { } } catch (err) { - console.error(err) + App.container('logger').error(err) } return null; @@ -40,7 +41,7 @@ const extractDefaultPostgresCredentials = () => { } } catch (err) { - console.error(err) + App.container('logger').error(err) } return null; diff --git a/src/core/interfaces/ICoreContainers.ts b/src/core/interfaces/ICoreContainers.ts index a2106b1c1..140c65179 100644 --- a/src/core/interfaces/ICoreContainers.ts +++ b/src/core/interfaces/ICoreContainers.ts @@ -4,11 +4,10 @@ import { IDatabaseService } from '@src/core/domains/database/interfaces/IDatabas import { IEventService } from '@src/core/domains/events/interfaces/IEventService'; import { IRequestContext } from '@src/core/domains/express/interfaces/ICurrentRequest'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; +import { ILoggerService } from '@src/core/domains/logger/interfaces/ILoggerService'; import IValidatorService from '@src/core/domains/validator/interfaces/IValidatorService'; import readline from 'node:readline'; -import { ILoggerService } from '../domains/logger/interfaces/ILoggerService'; - export interface ICoreContainers { [key: string]: any; diff --git a/src/core/providers/CoreProviders.ts b/src/core/providers/CoreProviders.ts index 64ccfb7cf..269636de3 100644 --- a/src/core/providers/CoreProviders.ts +++ b/src/core/providers/CoreProviders.ts @@ -3,14 +3,13 @@ import ConsoleProvider from "@src/core/domains/console/providers/ConsoleProvider import DatabaseProvider from "@src/core/domains/database/providers/DatabaseProvider"; import EventProvider from "@src/core/domains/events/providers/EventProvider"; import ExpressProvider from "@src/core/domains/express/providers/ExpressProvider"; +import LoggerProvider from "@src/core/domains/logger/providers/LoggerProvider"; import MakeProvider from "@src/core/domains/make/providers/MakeProvider"; import MigrationProvider from "@src/core/domains/migrations/providers/MigrationProvider"; import SetupProvider from "@src/core/domains/setup/providers/SetupProvider"; import ValidatorProvider from "@src/core/domains/validator/providers/ValidatorProvider"; import { IProvider } from "@src/core/interfaces/IProvider"; -import LoggerProvider from "../domains/logger/providers/LoggerProvider"; - /** * Core providers for the framework * diff --git a/src/core/services/PackageJsonService.ts b/src/core/services/PackageJsonService.ts index 87fbb8b42..1f31c266d 100644 --- a/src/core/services/PackageJsonService.ts +++ b/src/core/services/PackageJsonService.ts @@ -1,4 +1,5 @@ import { IPackageJson, IPackageJsonService } from "@src/core/interfaces/IPackageJsonService"; +import { App } from "@src/core/services/App"; import { exec } from "child_process"; import fs from "fs"; import path from "path"; @@ -27,7 +28,7 @@ export default class PackageJsonService implements IPackageJsonService { */ async installPackage(name: string) { const cmd = `yarn add ${name}` - console.log('Running command: ', cmd) + App.container('logger').info('Running command: ', cmd) await execPromise(cmd); } @@ -45,7 +46,7 @@ export default class PackageJsonService implements IPackageJsonService { } const cmd = `yarn remove ${name}` - console.log('Running command: ', cmd) + App.container('logger').info('Running command: ', cmd) await execPromise(cmd); } diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index 4b7599b88..2e36c608d 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -78,7 +78,7 @@ describe('attempt to run app with normal appConfig', () => { }); if(!result.success) { - console.error(result.joi.error); + App.container('logger').error(result.joi.error); } expect(result.success).toBeTruthy(); @@ -94,7 +94,7 @@ describe('attempt to run app with normal appConfig', () => { }); if(!result.success) { - console.error(result.joi.error); + App.container('logger').error(result.joi.error); } expect(result.success).toBeTruthy(); diff --git a/src/tests/database/dbPartialSearch.test.ts b/src/tests/database/dbPartialSearch.test.ts index 8605d267c..a60a1b5d8 100644 --- a/src/tests/database/dbPartialSearch.test.ts +++ b/src/tests/database/dbPartialSearch.test.ts @@ -51,7 +51,7 @@ describe('test partial search', () => { for(const connectionName of connections) { const documentManager = App.container('db').documentManager(connectionName).table('tests') as IDocumentManager - console.log('connectionName', connectionName) + App.container('logger').info('connectionName', connectionName) const recordOneData = { name: 'Test One', @@ -70,7 +70,7 @@ describe('test partial search', () => { const recordOne = await documentManager.findOne({ filter: { name: 'Test One'} }) const recordTwo = await documentManager.findOne({ filter: { name: 'Test Two'} }) - console.log('Created two records', recordOne, recordTwo) + App.container('logger').info('Created two records', recordOne, recordTwo) expect(recordOne.id).toBeTruthy() expect(recordTwo.id).toBeTruthy() @@ -78,17 +78,17 @@ describe('test partial search', () => { const recordBothPartial = await documentManager.findMany({ filter: { name: '%Test%' }, allowPartialSearch: true }) expect(recordBothPartial.length).toEqual(2) - console.log('recordBothPartial', recordBothPartial) + App.container('logger').info('recordBothPartial', recordBothPartial) const recordOnePartial = await documentManager.findOne({ filter: { name: '%One' }, allowPartialSearch: true }) expect(recordOnePartial?.id === recordOne.id).toBeTruthy() - console.log('recordOnePartial', recordOnePartial) + App.container('logger').info('recordOnePartial', recordOnePartial) const recordTwoPartial = await documentManager.findOne({ filter: { name: '%Two' }, allowPartialSearch: true }) expect(recordTwoPartial?.id === recordTwo.id).toBeTruthy() - console.log('recordTwoPartial', recordTwoPartial) + App.container('logger').info('recordTwoPartial', recordTwoPartial) } diff --git a/src/tests/database/dbProviders.test.ts b/src/tests/database/dbProviders.test.ts index 22aec099d..fd98ca0ff 100644 --- a/src/tests/database/dbProviders.test.ts +++ b/src/tests/database/dbProviders.test.ts @@ -63,13 +63,13 @@ describe('Combined DocumentManager Interface Test', () => { test('All DocumentManager operations', async () => { for (const connectionName of connections) { - console.log('[Connection]', connectionName); + App.container('logger').info('[Connection]', connectionName); const documentManager = App.container('db').documentManager(connectionName).table(tableName); await documentManager.truncate(); // Test insertOne and findById - console.log('--- Testing insertOne and findById ---'); + App.container('logger').info('--- Testing insertOne and findById ---'); await documentManager.truncate() const data = createDocument(); const insertedDoc = await documentManager.insertOne(data); @@ -84,7 +84,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(nonExistentDoc).toBeNull(); // Test findOne - console.log('--- Testing findOne ---'); + App.container('logger').info('--- Testing findOne ---'); await documentManager.truncate() const findOneData = createDocument() await documentManager.insertOne(findOneData) @@ -97,7 +97,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(nonExistentOneDoc).toBeNull(); // Test insertMany and findMany - console.log('--- Testing insertMany and findMany ---'); + App.container('logger').info('--- Testing insertMany and findMany ---'); await documentManager.truncate() const data1 = createDocument(); const data2 = createDocument(); @@ -113,7 +113,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(noResults.length).toBe(0); // Test updateOne - console.log('--- Testing updateOne ---'); + App.container('logger').info('--- Testing updateOne ---'); await documentManager.truncate() const updateOneData = createDocument() const updateOneInsertedDocument = await documentManager.insertOne(updateOneData); @@ -124,7 +124,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(updatedDoc?.age).toEqual(updateOneData.age); // Test updateMany - console.log('--- Testing updateMany ---'); + App.container('logger').info('--- Testing updateMany ---'); await documentManager.truncate() await documentManager.insertMany([createDocument(), createDocument(), createDocument()]); const allDocs = await documentManager.findMany({}); @@ -135,7 +135,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(updatedDocs.length).toBeGreaterThanOrEqual(3); // Test belongsTo - console.log('--- Testing belongsTo ---'); + App.container('logger').info('--- Testing belongsTo ---'); const parentDoc = await documentManager.insertOne({ name: 'Parent', age: 50 @@ -156,7 +156,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(relatedChildDoc?.age).toEqual(childDoc.age); // Test deleteOne - console.log('--- Testing deleteOne ---'); + App.container('logger').info('--- Testing deleteOne ---'); await documentManager.truncate() const docToDelete = await documentManager.insertOne(createDocument()); await documentManager.deleteOne(docToDelete); @@ -167,7 +167,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(deletedDoc).toBeNull(); // Test deleteMany - console.log('--- Testing deleteMany ---'); + App.container('logger').info('--- Testing deleteMany ---'); await documentManager.truncate() await documentManager.insertMany([createDocument(), createDocument(), createDocument()]); const docsBeforeDelete = await documentManager.findMany({}); @@ -177,7 +177,7 @@ describe('Combined DocumentManager Interface Test', () => { expect(remainingDocs.length).toBe(0); // Test truncate - console.log('--- Testing truncate ---'); + App.container('logger').info('--- Testing truncate ---'); await documentManager.insertMany([createDocument(), createDocument()]); await documentManager.truncate(); await documentManager.findMany({}); diff --git a/src/tests/events/listeners/TestListener.ts b/src/tests/events/listeners/TestListener.ts index f2d3b5165..44694bda9 100644 --- a/src/tests/events/listeners/TestListener.ts +++ b/src/tests/events/listeners/TestListener.ts @@ -1,9 +1,10 @@ import EventListener from "@src/core/domains/events/services/EventListener"; +import { App } from "@src/core/services/App"; export class TestListener extends EventListener { handle = async (payload: any) => { - console.log('[TestListener]', payload) + App.container('logger').info('[TestListener]', payload) } } \ No newline at end of file diff --git a/src/tests/events/listeners/TestQueueListener.ts b/src/tests/events/listeners/TestQueueListener.ts index f4de063b5..719d278c1 100644 --- a/src/tests/events/listeners/TestQueueListener.ts +++ b/src/tests/events/listeners/TestQueueListener.ts @@ -1,10 +1,11 @@ import EventListener from "@src/core/domains/events/services/EventListener"; +import { App } from "@src/core/services/App"; import { TestMovieModel } from "@src/tests/models/models/TestMovie"; export class TestQueueListener extends EventListener<{name: string}> { handle = async (payload: {name: string}) => { - console.log('[TestQueueListener]', { name: payload }) + App.container('logger').info('[TestQueueListener]', { name: payload }) const movie = new TestMovieModel({ name: payload.name diff --git a/src/tests/models/modelBelongsTo.test.ts b/src/tests/models/modelBelongsTo.test.ts index e9736d028..1483360db 100644 --- a/src/tests/models/modelBelongsTo.test.ts +++ b/src/tests/models/modelBelongsTo.test.ts @@ -11,7 +11,6 @@ import { DataTypes } from 'sequelize'; const tableName = 'tests'; const connections = getTestConnectionNames() -console.log('modelBelongsTo', connections) const createTable = async (connectionName: string) => { const schema = App.container('db').schema(connectionName); @@ -54,7 +53,7 @@ describe('test belongsTo by fetching an author from a movie', () => { test('belongsTo', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) await dropTable(connectionName) await createTable(connectionName) diff --git a/src/tests/models/modelCrud.test.ts b/src/tests/models/modelCrud.test.ts index b63e16838..9d7e8fc64 100644 --- a/src/tests/models/modelCrud.test.ts +++ b/src/tests/models/modelCrud.test.ts @@ -50,7 +50,7 @@ describe('test model crud', () => { test('CRUD', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) App.container('db').setDefaultConnectionName(connectionName); const documentManager = App.container('db').documentManager(connectionName).table('tests'); diff --git a/src/tests/models/modelHasMany.test.ts b/src/tests/models/modelHasMany.test.ts index 0a3b16391..136f966b1 100644 --- a/src/tests/models/modelHasMany.test.ts +++ b/src/tests/models/modelHasMany.test.ts @@ -56,7 +56,7 @@ describe('test hasMany', () => { test('hasMany', async () => { for(const connectionName of connections) { - console.log('[Connection]', connectionName) + App.container('logger').info('[Connection]', connectionName) App.container('db').setDefaultConnectionName(connectionName); await truncate(connectionName); diff --git a/src/tests/validator/validator.test.ts b/src/tests/validator/validator.test.ts index c689932cd..a2f0fb794 100644 --- a/src/tests/validator/validator.test.ts +++ b/src/tests/validator/validator.test.ts @@ -46,6 +46,6 @@ describe('test validation', () => { expect(result.success).toBeFalsy(); expect(result.joi.error).toBeTruthy(); - console.log('failed validation', result.joi.error) + App.container('logger').warn('failed validation', result.joi.error) }) }); \ No newline at end of file diff --git a/src/tinker.ts b/src/tinker.ts index 19ae28a39..5680915ea 100644 --- a/src/tinker.ts +++ b/src/tinker.ts @@ -3,6 +3,7 @@ import 'tsconfig-paths/register'; import appConfig from '@src/config/app'; import Kernel from "@src/core/Kernel"; +import { App } from '@src/core/services/App'; (async () => { @@ -17,5 +18,5 @@ import Kernel from "@src/core/Kernel"; // Add your tinkers below - + App.container('logger').info('Tinkers are ready!') })(); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 42c86d7d8..1e5dfdb37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,11 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -303,6 +308,15 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@dabh/diagnostics@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" + integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -982,6 +996,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + "@types/validator@^13.7.17": version "13.12.1" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.1.tgz#8835d22f7e25b261e624d02a42fe4ade2c689a3c" @@ -1243,6 +1262,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -1658,6 +1684,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -1822,7 +1856,7 @@ collect-v8-coverage@^1.0.0: resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -1841,21 +1875,45 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.6.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +color@^3.1.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== + dependencies: + color-convert "^1.9.3" + color-string "^1.6.0" + colorette@^2.0.14: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorspace@1.1.x: + version "1.1.4" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" + integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== + dependencies: + color "^3.1.3" + text-hex "1.0.x" + commander@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" @@ -2124,6 +2182,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2458,7 +2521,12 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -events@^3.2.0: +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -2581,6 +2649,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -2654,6 +2727,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -2973,7 +3051,7 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -3082,6 +3160,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -3811,6 +3894,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -3898,6 +3986,18 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +logform@^2.6.0, logform@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.6.1.tgz#71403a7d8cae04b2b734147963236205db9b3df0" + integrity sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + lru-cache@^11.0.0: version "11.0.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.1.tgz#3a732fbfedb82c5ba7bca6564ad3f42afcb6e147" @@ -4339,6 +4439,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" @@ -4640,6 +4747,11 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -4750,6 +4862,17 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -4868,6 +4991,11 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -5040,6 +5168,13 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" @@ -5146,6 +5281,11 @@ ssri@^8.0.0, ssri@^8.0.1: dependencies: minipass "^3.1.1" +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -5221,7 +5361,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -5368,6 +5508,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -5412,6 +5557,11 @@ tr46@^4.1.1: dependencies: punycode "^2.3.0" +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + ts-api-utils@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" @@ -5811,6 +5961,32 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== +winston-transport@^4.7.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.8.0.tgz#a15080deaeb80338455ac52c863418c74fcf38ea" + integrity sha512-qxSTKswC6llEMZKgCQdaWgDuMJQnhuvF5f2Nk3SNXc4byfQ+voo2mX1Px9dkNOuR8p0KAjfPG29PuYUSIb+vSA== + dependencies: + logform "^2.6.1" + readable-stream "^4.5.2" + triple-beam "^1.3.0" + +winston@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.15.0.tgz#4df7b70be091bc1a38a4f45b969fa79589b73ff5" + integrity sha512-RhruH2Cj0bV0WgNL+lOfoUBI4DVfdUNjVnJGVovWZmrcKtrFTTRzgXYK2O9cymSGjrERCtaAeHwMNnUWXlwZow== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.6.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.7.0" + wkx@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c" From c74f8affb0d2669c04afc7dff7f2e6e914203e52 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 14:01:11 +0100 Subject: [PATCH 68/76] feat(model): added attr method shorthand for setAttribute and getAttribute --- src/core/base/Model.ts | 30 +++++++++++++++++++++++++++--- src/core/interfaces/IModelData.ts | 2 +- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 93af807d2..8d513a036 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -126,7 +126,27 @@ export default abstract class Model extends WithObserve * @returns {string | undefined} The primary key value or undefined if not set. */ getId(): string | undefined { - return this.data?.[this.primaryKey]; + return this.data?.[this.primaryKey] as string | undefined; + } + + /** + * Sets or retrieves the value of a specific attribute from the model's data. + * If called with a single argument, returns the value of the attribute. + * If called with two arguments, sets the value of the attribute. + * If the value is not set, returns null. + * + * @template K Type of the attribute key. + * @param {K} key - The key of the attribute to retrieve or set. + * @param {any} [value] - The value to set for the attribute. + * @returns {Data[K] | null | undefined} The value of the attribute or null if not found, or undefined if setting. + */ + attr(key: K, value?: unknown): Data[K] | null | undefined { + if(value === undefined) { + return this.getAttribute(key) as Data[K] ?? null; + } + + this.setAttribute(key, value); + return undefined; } /** @@ -148,7 +168,11 @@ export default abstract class Model extends WithObserve * @param {any} value - The value to set for the attribute. * @throws {Error} If the attribute is not in the allowed fields or if a date field is set with a non-Date value. */ - setAttribute(key: K, value: any): void { + setAttribute(key: K, value?: unknown): void { + if(this.data === null) { + this.data = {} as Data; + } + if (!this.fields.includes(key as string)) { throw new Error(`Attribute ${key as string} not found in model ${this.constructor.name}`); } @@ -156,7 +180,7 @@ export default abstract class Model extends WithObserve throw new Error(`Attribute '${key as string}' is a date and can only be set with a Date object in model ${this.table}`); } if (this.data) { - this.data[key] = value; + this.data[key] = value as Data[K]; } if (Object.keys(this.observeProperties).includes(key as string)) { diff --git a/src/core/interfaces/IModelData.ts b/src/core/interfaces/IModelData.ts index 3423d3abc..af1829dca 100644 --- a/src/core/interfaces/IModelData.ts +++ b/src/core/interfaces/IModelData.ts @@ -10,5 +10,5 @@ export default interface IModelData { id?: string; createdAt?: Date; updatedAt?: Date; - [key: string]: any; + [key: string]: unknown; } From 601ad96ed36ad986124fe1bac25cb415b3db1608 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 14:01:30 +0100 Subject: [PATCH 69/76] refator(tests): Added missing logger provider to tests --- src/tests/auth/auth.test.ts | 1 + src/tests/config/testConfig.ts | 5 +++- src/tests/database/dbConnection.test.ts | 1 + src/tests/database/dbPartialSearch.test.ts | 1 + src/tests/database/dbProviders.test.ts | 1 + src/tests/database/dbSchema.test.ts | 1 + src/tests/endTests.test.ts | 1 + src/tests/events/eventQueue.test.ts | 1 + src/tests/events/eventSync.test.ts | 8 ++++-- src/tests/make/make.test.ts | 1 + src/tests/migration/migration.test.ts | 1 + src/tests/models/modelAttr.test.ts | 33 ++++++++++++++++++++++ src/tests/models/modelBelongsTo.test.ts | 1 + src/tests/models/modelCrud.test.ts | 1 + src/tests/models/modelHasMany.test.ts | 1 + src/tests/validator/validator.test.ts | 1 + 16 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/tests/models/modelAttr.test.ts diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index 2e36c608d..b42580b39 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -26,6 +26,7 @@ describe('attempt to run app with normal appConfig', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider(), new DatabaseProvider(), new AuthProvider() diff --git a/src/tests/config/testConfig.ts b/src/tests/config/testConfig.ts index b51c462bd..0402cdbc7 100644 --- a/src/tests/config/testConfig.ts +++ b/src/tests/config/testConfig.ts @@ -1,4 +1,5 @@ import { EnvironmentTesting } from '@src/core/consts/Environment'; +import LoggerProvider from '@src/core/domains/logger/providers/LoggerProvider'; import IAppConfig from '@src/core/interfaces/IAppConfig'; require('dotenv').config(); @@ -6,7 +7,9 @@ require('dotenv').config(); const testAppConfig: IAppConfig = { environment: EnvironmentTesting, - providers: [] + providers: [ + new LoggerProvider() + ] }; export default testAppConfig; diff --git a/src/tests/database/dbConnection.test.ts b/src/tests/database/dbConnection.test.ts index aa3ec7e99..d0512bafb 100644 --- a/src/tests/database/dbConnection.test.ts +++ b/src/tests/database/dbConnection.test.ts @@ -14,6 +14,7 @@ describe('attempt to connect to MongoDB database', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new DatabaseProvider() ] }, {}) diff --git a/src/tests/database/dbPartialSearch.test.ts b/src/tests/database/dbPartialSearch.test.ts index a60a1b5d8..c6318688a 100644 --- a/src/tests/database/dbPartialSearch.test.ts +++ b/src/tests/database/dbPartialSearch.test.ts @@ -35,6 +35,7 @@ describe('test partial search', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) diff --git a/src/tests/database/dbProviders.test.ts b/src/tests/database/dbProviders.test.ts index fd98ca0ff..5cbe2d790 100644 --- a/src/tests/database/dbProviders.test.ts +++ b/src/tests/database/dbProviders.test.ts @@ -45,6 +45,7 @@ describe('Combined DocumentManager Interface Test', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}); diff --git a/src/tests/database/dbSchema.test.ts b/src/tests/database/dbSchema.test.ts index 7184ff62c..8c0a31f2a 100644 --- a/src/tests/database/dbSchema.test.ts +++ b/src/tests/database/dbSchema.test.ts @@ -36,6 +36,7 @@ describe('Combined DocumentManager Interface Test', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}); diff --git a/src/tests/endTests.test.ts b/src/tests/endTests.test.ts index 5951e7c34..3dbf423cb 100644 --- a/src/tests/endTests.test.ts +++ b/src/tests/endTests.test.ts @@ -17,6 +17,7 @@ describe('clean up tables', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) diff --git a/src/tests/events/eventQueue.test.ts b/src/tests/events/eventQueue.test.ts index d8b578f5c..4aba828d6 100644 --- a/src/tests/events/eventQueue.test.ts +++ b/src/tests/events/eventQueue.test.ts @@ -46,6 +46,7 @@ describe('mock event service', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider(), new TestConsoleProvider(), new TestEventProvider() diff --git a/src/tests/events/eventSync.test.ts b/src/tests/events/eventSync.test.ts index e8b1e2445..ca3ece723 100644 --- a/src/tests/events/eventSync.test.ts +++ b/src/tests/events/eventSync.test.ts @@ -6,6 +6,8 @@ import TestSubscriber from '@src/tests/events/subscribers/TestSyncSubscriber'; import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; import TestEventProvider from '@src/tests/providers/TestEventProvider'; +import testAppConfig from '../config/testConfig'; + describe('mock event service', () => { /** @@ -13,12 +15,12 @@ describe('mock event service', () => { */ beforeAll(async () => { await Kernel.boot({ - environment: 'testing', + ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider(), new TestEventProvider() - ], - commands: [] + ] }, {}) }) diff --git a/src/tests/make/make.test.ts b/src/tests/make/make.test.ts index c7f52a07f..2bba589fe 100644 --- a/src/tests/make/make.test.ts +++ b/src/tests/make/make.test.ts @@ -15,6 +15,7 @@ describe(`testing make commands (total ${makeTypes.length})`, () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider() ] }, {}) diff --git a/src/tests/migration/migration.test.ts b/src/tests/migration/migration.test.ts index fe7ea1f42..f4b713690 100644 --- a/src/tests/migration/migration.test.ts +++ b/src/tests/migration/migration.test.ts @@ -16,6 +16,7 @@ describe('test migrations', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestConsoleProvider(), new TestDatabaseProvider(), new TestMigrationProvider(), diff --git a/src/tests/models/modelAttr.test.ts b/src/tests/models/modelAttr.test.ts new file mode 100644 index 000000000..ac38e35f7 --- /dev/null +++ b/src/tests/models/modelAttr.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Kernel from '@src/core/Kernel'; +import testAppConfig from '@src/tests/config/testConfig'; +import TestModel from '@src/tests/models/models/TestModel'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; + +describe('test model attr', () => { + + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestDatabaseProvider() + ] + }, {}) + }) + + test('attr', async () => { + const model = new TestModel({ + name: 'John' + }); + expect(model.attr('name')).toEqual('John'); + + model.attr('name', 'Jane'); + expect(model.attr('name')).toEqual('Jane'); + + const modelNoProperties = new TestModel(null); + modelNoProperties.attr('name', 'John') + expect(modelNoProperties.attr('name')).toEqual('John'); + }) +}); \ No newline at end of file diff --git a/src/tests/models/modelBelongsTo.test.ts b/src/tests/models/modelBelongsTo.test.ts index 1483360db..15c323b05 100644 --- a/src/tests/models/modelBelongsTo.test.ts +++ b/src/tests/models/modelBelongsTo.test.ts @@ -42,6 +42,7 @@ describe('test belongsTo by fetching an author from a movie', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) diff --git a/src/tests/models/modelCrud.test.ts b/src/tests/models/modelCrud.test.ts index 9d7e8fc64..59bfb95e1 100644 --- a/src/tests/models/modelCrud.test.ts +++ b/src/tests/models/modelCrud.test.ts @@ -36,6 +36,7 @@ describe('test model crud', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) diff --git a/src/tests/models/modelHasMany.test.ts b/src/tests/models/modelHasMany.test.ts index 136f966b1..e5ecd11cc 100644 --- a/src/tests/models/modelHasMany.test.ts +++ b/src/tests/models/modelHasMany.test.ts @@ -44,6 +44,7 @@ describe('test hasMany', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new TestDatabaseProvider() ] }, {}) diff --git a/src/tests/validator/validator.test.ts b/src/tests/validator/validator.test.ts index a2f0fb794..a4f105658 100644 --- a/src/tests/validator/validator.test.ts +++ b/src/tests/validator/validator.test.ts @@ -13,6 +13,7 @@ describe('test validation', () => { await Kernel.boot({ ...testAppConfig, providers: [ + ...testAppConfig.providers, new ValidationProvider() ] }, {}) From 60f075047d6bb868a2e72a8c261827019aa2055c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 14:12:29 +0100 Subject: [PATCH 70/76] feat(logger): Updated formatting of logger --- .../domains/logger/services/LoggerService.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/core/domains/logger/services/LoggerService.ts b/src/core/domains/logger/services/LoggerService.ts index 380b0afe3..84c448854 100644 --- a/src/core/domains/logger/services/LoggerService.ts +++ b/src/core/domains/logger/services/LoggerService.ts @@ -1,8 +1,6 @@ -import { EnvironmentProduction } from "@src/core/consts/Environment"; import { ILoggerService } from "@src/core/domains/logger/interfaces/ILoggerService"; -import { App } from "@src/core/services/App"; import path from "path"; -import winston from "winston"; +import winston, { format } from "winston"; class LoggerService implements ILoggerService { @@ -20,18 +18,22 @@ class LoggerService implements ILoggerService { return; } + const formatPrintf = (info: winston.Logform.TransformableInfo) => { + return `${info.timestamp} ${info.level}: ${info.message}`+(info.splat!==undefined?`${info.splat}`:" ") + } + const logger = winston.createLogger({ level:'info', - format: winston.format.json(), + format: winston.format.combine( + format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + format.printf(formatPrintf) + ), transports: [ + new winston.transports.Console({ format: winston.format.printf(formatPrintf) }), new winston.transports.File({ filename: path.resolve('@src/../', 'storage/logs/larascript.log') }) ] }) - if(App.env() !== EnvironmentProduction) { - logger.add(new winston.transports.Console({ format: winston.format.simple() })); - } - this.logger = logger } From 35f732f4a1282f7480274060857021116d59f85d Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 14:15:02 +0100 Subject: [PATCH 71/76] refactor(database,sequelize): Turned off SQL logging for production environment --- src/core/domains/database/providers-db/Postgres.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/domains/database/providers-db/Postgres.ts b/src/core/domains/database/providers-db/Postgres.ts index 0d98e80f4..24aea79dc 100644 --- a/src/core/domains/database/providers-db/Postgres.ts +++ b/src/core/domains/database/providers-db/Postgres.ts @@ -1,4 +1,5 @@ +import { EnvironmentProduction } from '@src/core/consts/Environment'; import PostgresDocumentManager from '@src/core/domains/database/documentManagers/PostgresDocumentManager'; import InvalidSequelize from '@src/core/domains/database/exceptions/InvalidSequelize'; import { IDatabaseGenericConnectionConfig } from '@src/core/domains/database/interfaces/IDatabaseGenericConnectionConfig'; @@ -6,6 +7,7 @@ import { IDatabaseProvider } from '@src/core/domains/database/interfaces/IDataba import { IDatabaseSchema } from '@src/core/domains/database/interfaces/IDatabaseSchema'; import { IDocumentManager } from '@src/core/domains/database/interfaces/IDocumentManager'; import PostgresSchema from '@src/core/domains/database/schema/PostgresSchema'; +import { App } from '@src/core/services/App'; import pg from 'pg'; import { QueryInterface, Sequelize } from 'sequelize'; import { Options, Options as SequelizeOptions } from 'sequelize/types/sequelize'; @@ -48,6 +50,7 @@ export default class Postgres implements IDatabaseProvider { */ async connect(): Promise { this.sequelize = new Sequelize(this.config.uri, { + logging: App.env() !== EnvironmentProduction, ...this.config.options, ...this.overrideConfig }) From d12cdad0d25e7926a3010f3657dc9e54a14a3784 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sat, 12 Oct 2024 23:09:25 +0100 Subject: [PATCH 72/76] feat(model): Added dirty methods to models, updated data property to Attributes, minor fixes for preparing documents, updated tests --- src/app/models/auth/User.ts | 4 +- src/core/base/Model.ts | 176 ++++++++++++------ .../domains/auth/factory/apiTokenFactory.ts | 2 +- .../domains/auth/interfaces/IApitokenModel.ts | 4 +- src/core/domains/auth/services/AuthService.ts | 10 +- .../MongoDbDocumentManager.ts | 14 ++ .../interfaces/relationships/IBelongsTo.ts | 8 +- .../interfaces/relationships/IHasMany.ts | 8 +- .../database/relationships/BelongsTo.ts | 4 +- .../domains/database/relationships/HasMany.ts | 6 +- .../relationships/mongodb/MongoDBHasMany.ts | 6 +- .../events/models/FailedWorkerModel.ts | 6 +- src/core/domains/events/models/WorkerModel.ts | 4 +- .../utils/stripGuardedResourceProperties.ts | 4 +- .../migrations/models/MigrationModel.ts | 4 +- .../exceptions/UnexpectedAttributeError.ts | 8 + src/core/interfaces/IModel.ts | 20 +- src/core/interfaces/IModelData.ts | 2 +- src/tests/auth/auth.test.ts | 2 +- src/tests/events/eventQueue.test.ts | 2 +- src/tests/events/eventSync.test.ts | 3 +- src/tests/models/modelDirty.test.ts | 148 +++++++++++++++ src/tests/models/modelHasMany.test.ts | 16 +- src/tests/models/models/TestAuthor.ts | 4 +- src/tests/models/models/TestDirtyModel.ts | 26 +++ src/tests/models/models/TestModel.ts | 3 +- src/tests/models/models/TestMovie.ts | 4 +- 27 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 src/core/exceptions/UnexpectedAttributeError.ts create mode 100644 src/tests/models/modelDirty.test.ts create mode 100644 src/tests/models/models/TestDirtyModel.ts diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index 6558eda4a..d0c056465 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -2,12 +2,12 @@ import ApiToken from "@src/app/models/auth/ApiToken"; import UserObserver from "@src/app/observers/UserObserver"; import Model from "@src/core/base/Model"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; /** * User structure */ -export interface IUserData extends IModelData { +export interface IUserData extends IModelAttributes { email: string; password?: string; hashedPassword: string; diff --git a/src/core/base/Model.ts b/src/core/base/Model.ts index 8d513a036..ffa7dca48 100644 --- a/src/core/base/Model.ts +++ b/src/core/base/Model.ts @@ -3,9 +3,10 @@ import { IBelongsToOptions } from '@src/core/domains/database/interfaces/relatio import { IHasManyOptions } from '@src/core/domains/database/interfaces/relationships/IHasMany'; import { IObserver } from '@src/core/domains/observer/interfaces/IObserver'; import { WithObserver } from '@src/core/domains/observer/services/WithObserver'; +import UnexpectedAttributeError from '@src/core/exceptions/UnexpectedAttributeError'; import { ICtor } from '@src/core/interfaces/ICtor'; import { GetDataOptions, IModel } from '@src/core/interfaces/IModel'; -import IModelData from '@src/core/interfaces/IModelData'; +import IModelAttributes from '@src/core/interfaces/IModelData'; import { App } from '@src/core/services/App'; import Str from '@src/core/util/str/Str'; @@ -15,9 +16,9 @@ import Str from '@src/core/util/str/Str'; * Extends WithObserver to provide observation capabilities. * Implements IModel interface for consistent model behavior. * - * @template Data Type extending IModelData, representing the structure of the model's data. + * @template Attributes Type extending IModelData, representing the structure of the model's data. */ -export default abstract class Model extends WithObserver implements IModel { +export default abstract class Model extends WithObserver implements IModel { public name!: string; @@ -37,7 +38,13 @@ export default abstract class Model extends WithObserve * The actual data of the model. * Can be null if the model hasn't been populated. */ - public data: Data | null; + public attributes: Attributes | null = null; + + /** + * The original data of the model. + * Can be null if the model hasn't been populated. + */ + public original: Attributes | null = null; /** * The name of the MongoDB collection associated with this model. @@ -84,13 +91,14 @@ export default abstract class Model extends WithObserve /** * Constructs a new instance of the Model class. * - * @param {Data | null} data - Initial data to populate the model. + * @param {Attributes | null} data - Initial data to populate the model. */ - constructor(data: Data | null) { + constructor(data: Attributes | null) { super(); - this.data = data; this.name = this.constructor.name; this.setDefaultTable(); + this.attributes = { ...data } as Attributes; + this.original = { ...data } as Attributes; } /** @@ -103,10 +111,10 @@ export default abstract class Model extends WithObserve } this.table = this.constructor.name; - if(this.table.endsWith('Model')) { + if (this.table.endsWith('Model')) { this.table = this.table.slice(0, -5); } - + this.table = Str.plural(Str.startLowerCase(this.table)) } @@ -126,7 +134,7 @@ export default abstract class Model extends WithObserve * @returns {string | undefined} The primary key value or undefined if not set. */ getId(): string | undefined { - return this.data?.[this.primaryKey] as string | undefined; + return this.attributes?.[this.primaryKey] as string | undefined; } /** @@ -138,11 +146,11 @@ export default abstract class Model extends WithObserve * @template K Type of the attribute key. * @param {K} key - The key of the attribute to retrieve or set. * @param {any} [value] - The value to set for the attribute. - * @returns {Data[K] | null | undefined} The value of the attribute or null if not found, or undefined if setting. + * @returns {Attributes[K] | null | undefined} The value of the attribute or null if not found, or undefined if setting. */ - attr(key: K, value?: unknown): Data[K] | null | undefined { - if(value === undefined) { - return this.getAttribute(key) as Data[K] ?? null; + attr(key: K, value?: unknown): Attributes[K] | null | undefined { + if (value === undefined) { + return this.getAttribute(key) as Attributes[K] ?? null; } this.setAttribute(key, value); @@ -154,10 +162,62 @@ export default abstract class Model extends WithObserve * * @template K Type of the attribute key. * @param {K} key - The key of the attribute to retrieve. - * @returns {Data[K] | null} The value of the attribute or null if not found. + * @returns {Attributes[K] | null} The value of the attribute or null if not found. */ - getAttribute(key: K): Data[K] | null { - return this.data?.[key] ?? null; + getAttribute(key: K): Attributes[K] | null { + return this.attributes?.[key] ?? null; + } + + /** + * Retrieves the original value of a specific attribute from the model's original data. + * + * @template K Type of the attribute key. + * @param {K} key - The key of the attribute to retrieve. + * @returns {Attributes[K] | null} The original value of the attribute or null if not found. + */ + getOriginal(key: K): Attributes[K] | null { + return this.original?.[key] ?? null; + } + + /** + * Checks if the model is dirty. + * + * A model is considered dirty if any of its attributes have changed since the last time the model was saved. + * + * @returns {boolean} True if the model is dirty, false otherwise. + */ + isDirty(): boolean { + if(!this.original) { + return false; + } + return Object.keys(this.getDirty() ?? {}).length > 0; + } + + /** + * Gets the dirty attributes. + * @returns + */ + getDirty(): Record | null { + + const dirty = {} as Record; + + Object.entries(this.attributes as object).forEach(([key, value]) => { + + try { + if (typeof value === 'object' && JSON.stringify(value) !== JSON.stringify(this.original?.[key])) { + dirty[key as keyof Attributes] = value; + return; + } + } + // eslint-disable-next-line no-unused-vars + catch (e) { } + + if (value !== this.original?.[key]) { + dirty[key as keyof Attributes] = value; + } + }); + + return dirty; } /** @@ -168,24 +228,23 @@ export default abstract class Model extends WithObserve * @param {any} value - The value to set for the attribute. * @throws {Error} If the attribute is not in the allowed fields or if a date field is set with a non-Date value. */ - setAttribute(key: K, value?: unknown): void { - if(this.data === null) { - this.data = {} as Data; - } - + setAttribute(key: K, value?: unknown): void { if (!this.fields.includes(key as string)) { - throw new Error(`Attribute ${key as string} not found in model ${this.constructor.name}`); + throw new UnexpectedAttributeError(`Unexpected attribute '${key as string}'`); } if (this.dates.includes(key as string) && !(value instanceof Date)) { - throw new Error(`Attribute '${key as string}' is a date and can only be set with a Date object in model ${this.table}`); + throw new UnexpectedAttributeError(`Unexpected attribute value. Expected attribute '${key as string}' value to be of type Date`); + } + if (this.attributes === null) { + this.attributes = {} as Attributes; } - if (this.data) { - this.data[key] = value as Data[K]; + if (this.attributes) { + this.attributes[key] = value as Attributes[K]; } if (Object.keys(this.observeProperties).includes(key as string)) { - this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.data).then((data) => { - this.data = data; + this.observeDataCustom(this.observeProperties[key as string] as keyof IObserver, this.attributes).then((data) => { + this.attributes = data; }) } } @@ -206,9 +265,9 @@ export default abstract class Model extends WithObserve /** * Fills the model with the provided data. * - * @param {Partial} data - The data to fill the model with. + * @param {Partial} data - The data to fill the model with. */ - fill(data: Partial): void { + fill(data: Partial): void { Object.entries(data) // eslint-disable-next-line no-unused-vars .filter(([_key, value]) => value !== undefined) @@ -221,15 +280,15 @@ export default abstract class Model extends WithObserve * Retrieves the data from the model. * * @param {GetDataOptions} [options={ excludeGuarded: true }] - Options for data retrieval. - * @returns {Data | null} The model's data, potentially excluding guarded fields. + * @returns {Attributes | null} The model's data, potentially excluding guarded fields. */ - getData(options: GetDataOptions = { excludeGuarded: true }): Data | null { - let data = this.data; + getData(options: GetDataOptions = { excludeGuarded: true }): Attributes | null { + let data = this.attributes; if (data && options.excludeGuarded) { data = Object.fromEntries( Object.entries(data).filter(([key]) => !this.guarded.includes(key)) - ) as Data; + ) as Attributes; } return data; @@ -238,16 +297,17 @@ export default abstract class Model extends WithObserve /** * Refreshes the model's data from the database. * - * @returns {Promise} The refreshed data or null if the model has no ID. + * @returns {Promise} The refreshed data or null if the model has no ID. */ - async refresh(): Promise { + async refresh(): Promise { const id = this.getId(); if (!id) return null; - this.data = await this.getDocumentManager().findById(id); + this.attributes = await this.getDocumentManager().findById(id); + this.original = { ...this.attributes } as Attributes - return this.data; + return this.attributes; } /** @@ -256,7 +316,7 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async update(): Promise { - if (!this.getId() || !this.data) return; + if (!this.getId() || !this.attributes) return; await this.getDocumentManager().updateOne(this.prepareDocument()); } @@ -269,7 +329,7 @@ export default abstract class Model extends WithObserve * @returns {T} The prepared document. */ prepareDocument(): T { - return this.getDocumentManager().prepareDocument({ ...this.data }, { + return this.getDocumentManager().prepareDocument({ ...this.attributes }, { jsonStringify: this.json }) as T; } @@ -281,23 +341,24 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async save(): Promise { - if (this.data && !this.getId()) { - this.data = await this.observeData('creating', this.data); + if (this.attributes && !this.getId()) { + this.attributes = await this.observeData('creating', this.attributes); this.setTimestamp('createdAt'); this.setTimestamp('updatedAt'); - this.data = await this.getDocumentManager().insertOne(this.prepareDocument()); - await this.refresh(); + this.attributes = await this.getDocumentManager().insertOne(this.prepareDocument()); + this.attributes = await this.refresh(); - this.data = await this.observeData('created', this.data); + this.attributes = await this.observeData('created', this.attributes); return; } - this.data = await this.observeData('updating', this.data); + this.attributes = await this.observeData('updating', this.attributes); this.setTimestamp('updatedAt'); await this.update(); - await this.refresh(); - this.data = await this.observeData('updated', this.data); + this.attributes = await this.refresh(); + this.attributes = await this.observeData('updated', this.attributes); + this.original = { ...this.attributes } } /** @@ -306,11 +367,12 @@ export default abstract class Model extends WithObserve * @returns {Promise} */ async delete(): Promise { - if (!this.data) return; - this.data = await this.observeData('deleting', this.data); - await this.getDocumentManager().deleteOne(this.data); - this.data = null; - await this.observeData('deleted', this.data); + if (!this.attributes) return; + this.attributes = await this.observeData('deleting', this.attributes); + await this.getDocumentManager().deleteOne(this.attributes); + this.attributes = null; + this.original = null; + await this.observeData('deleted', this.attributes); } /** @@ -324,11 +386,11 @@ export default abstract class Model extends WithObserve async belongsTo(foreignModel: ICtor, options: Omit): Promise { const documentManager = App.container('db').documentManager(this.connection); - if (!this.data) { + if (!this.attributes) { return null; } - const result = await documentManager.belongsTo(this.data, { + const result = await documentManager.belongsTo(this.attributes, { ...options, foreignTable: (new foreignModel()).table }); @@ -351,11 +413,11 @@ export default abstract class Model extends WithObserve public async hasMany(foreignModel: ICtor, options: Omit): Promise { const documentManager = App.container('db').documentManager(this.connection); - if (!this.data) { + if (!this.attributes) { return []; } - const results = await documentManager.hasMany(this.data, { + const results = await documentManager.hasMany(this.attributes, { ...options, foreignTable: (new foreignModel()).table }); diff --git a/src/core/domains/auth/factory/apiTokenFactory.ts b/src/core/domains/auth/factory/apiTokenFactory.ts index 5f86e45a9..3f4bcfb6e 100644 --- a/src/core/domains/auth/factory/apiTokenFactory.ts +++ b/src/core/domains/auth/factory/apiTokenFactory.ts @@ -24,7 +24,7 @@ class ApiTokenFactory extends Factory { */ createFromUser(user: IUserModel, scopes: string[] = []): IApiTokenModel { return new this.modelCtor({ - userId: user.data?.id, + userId: user.attributes?.id, token: tokenFactory(), scopes: scopes, revokedAt: null, diff --git a/src/core/domains/auth/interfaces/IApitokenModel.ts b/src/core/domains/auth/interfaces/IApitokenModel.ts index 0921c3461..c957ecbe5 100644 --- a/src/core/domains/auth/interfaces/IApitokenModel.ts +++ b/src/core/domains/auth/interfaces/IApitokenModel.ts @@ -1,8 +1,8 @@ /* eslint-disable no-unused-vars */ import { IModel } from "@src/core/interfaces/IModel"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -export interface IApiTokenData extends IModelData { +export interface IApiTokenData extends IModelAttributes { userId: string; token: string; scopes: string[]; diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index 7a057115b..c7b416ab4 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -80,10 +80,10 @@ export default class AuthService extends Service implements IAuthSe * @returns */ jwt(apiToken: IApiTokenModel): string { - if (!apiToken?.data?.userId) { + if (!apiToken?.attributes?.userId) { throw new Error('Invalid token'); } - const payload = JWTTokenFactory.create(apiToken.data?.userId?.toString(), apiToken.data?.token); + const payload = JWTTokenFactory.create(apiToken.attributes?.userId?.toString(), apiToken.attributes?.token); return createJwt(this.config.jwtSecret, payload, `${this.config.expiresInMinutes}m`); } @@ -93,7 +93,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async revokeToken(apiToken: IApiTokenModel): Promise { - if (apiToken?.data?.revokedAt) { + if (apiToken?.attributes?.revokedAt) { return; } @@ -142,11 +142,11 @@ export default class AuthService extends Service implements IAuthSe async attemptCredentials(email: string, password: string, scopes: string[] = []): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; - if (!user?.data?.id) { + if (!user?.attributes?.id) { throw new UnauthorizedError() } - if (user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { + if (user?.attributes?.hashedPassword && !comparePassword(password, user.attributes?.hashedPassword)) { throw new UnauthorizedError() } diff --git a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts index ec5b1dbba..29365154f 100644 --- a/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts +++ b/src/core/domains/database/documentManagers/MongoDbDocumentManager.ts @@ -2,6 +2,7 @@ import BaseDocumentManager from "@src/core/domains/database/base/BaseDocumentMan import MongoDbQueryBuilder from "@src/core/domains/database/builder/MongoDbQueryBuilder"; import InvalidObjectId from "@src/core/domains/database/exceptions/InvalidObjectId"; import { FindOptions, IDatabaseDocument, OrderOptions } from "@src/core/domains/database/interfaces/IDocumentManager"; +import { IPrepareOptions } from "@src/core/domains/database/interfaces/IPrepareOptions"; import { IBelongsToOptions } from "@src/core/domains/database/interfaces/relationships/IBelongsTo"; import MongoDB from "@src/core/domains/database/providers-db/MongoDB"; import MongoDBBelongsTo from "@src/core/domains/database/relationships/mongodb/MongoDBBelongsTo"; @@ -18,6 +19,19 @@ class MongoDbDocumentManager extends BaseDocumentManager IBelongsTo; export interface IBelongsToOptions { - localKey: keyof IModelData; - foreignKey: keyof IModelData; + localKey: keyof IModelAttributes; + foreignKey: keyof IModelAttributes; foreignTable: string; filters?: object; } @@ -16,5 +16,5 @@ export interface IBelongsTo { connection: string, document: IDatabaseDocument, options: IBelongsToOptions - ): Promise; + ): Promise; } \ No newline at end of file diff --git a/src/core/domains/database/interfaces/relationships/IHasMany.ts b/src/core/domains/database/interfaces/relationships/IHasMany.ts index 9584c428a..75efa502f 100644 --- a/src/core/domains/database/interfaces/relationships/IHasMany.ts +++ b/src/core/domains/database/interfaces/relationships/IHasMany.ts @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars */ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; export type IHasManyCtor = new () => IHasMany; export interface IHasManyOptions { - localKey: keyof IModelData; - foreignKey: keyof IModelData; + localKey: keyof IModelAttributes; + foreignKey: keyof IModelAttributes; foreignTable: string; filters?: object; } @@ -16,5 +16,5 @@ export interface IHasMany { connection: string, document: IDatabaseDocument, options: IHasManyOptions - ): Promise; + ): Promise; } \ No newline at end of file diff --git a/src/core/domains/database/relationships/BelongsTo.ts b/src/core/domains/database/relationships/BelongsTo.ts index be0e66166..73e23a97a 100644 --- a/src/core/domains/database/relationships/BelongsTo.ts +++ b/src/core/domains/database/relationships/BelongsTo.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IBelongsTo, IBelongsToOptions } from "@src/core/domains/database/interfaces/relationships/IBelongsTo"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; /** @@ -18,7 +18,7 @@ export default class BelongsTo implements IBelongsTo { * @param options - The relationship options. * @returns The related document or null if not found. */ - public async handle(connection: string, document: IDatabaseDocument, options: IBelongsToOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IBelongsToOptions): Promise { /** * Get the local key and foreign key from the options. diff --git a/src/core/domains/database/relationships/HasMany.ts b/src/core/domains/database/relationships/HasMany.ts index d4d5dee79..c3c8b6dcb 100644 --- a/src/core/domains/database/relationships/HasMany.ts +++ b/src/core/domains/database/relationships/HasMany.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IHasMany, IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; /** @@ -17,9 +17,9 @@ export default class HasMany implements IHasMany { * @param {string} connection - The connection name. * @param {IDatabaseDocument} document - The source document. * @param {IHasManyOptions} options - The relationship options. - * @returns {Promise} The related documents. + * @returns {Promise} The related documents. */ - public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { // Get the local key, foreign key, foreign table, and filters from the options. const { localKey, diff --git a/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts b/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts index 6c5108cde..1e0fb7372 100644 --- a/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts +++ b/src/core/domains/database/relationships/mongodb/MongoDBHasMany.ts @@ -1,6 +1,6 @@ import { IDatabaseDocument } from "@src/core/domains/database/interfaces/IDocumentManager"; import { IHasMany, IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { App } from "@src/core/services/App"; import { ObjectId } from "mongodb"; @@ -18,9 +18,9 @@ export default class HasMany implements IHasMany { * @param {string} connection - The connection name. * @param {IDatabaseDocument} document - The source document. * @param {IHasManyOptions} options - The relationship options. - * @returns {Promise} The related documents. + * @returns {Promise} The related documents. */ - public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { + public async handle(connection: string, document: IDatabaseDocument, options: IHasManyOptions): Promise { const { foreignTable, diff --git a/src/core/domains/events/models/FailedWorkerModel.ts b/src/core/domains/events/models/FailedWorkerModel.ts index 4986de818..99e3ef778 100644 --- a/src/core/domains/events/models/FailedWorkerModel.ts +++ b/src/core/domains/events/models/FailedWorkerModel.ts @@ -1,13 +1,13 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; /** * Represents a failed worker model. * * @interface FailedWorkerModelData - * @extends IModelData + * @extends IModelAttributes */ -export interface FailedWorkerModelData extends IModelData { +export interface FailedWorkerModelData extends IModelAttributes { /** * The name of the event that failed. diff --git a/src/core/domains/events/models/WorkerModel.ts b/src/core/domains/events/models/WorkerModel.ts index 1bc3c159a..bb966ab6e 100644 --- a/src/core/domains/events/models/WorkerModel.ts +++ b/src/core/domains/events/models/WorkerModel.ts @@ -1,7 +1,7 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -export interface WorkerModelData extends IModelData { +export interface WorkerModelData extends IModelAttributes { queueName: string; eventName: string; payload: any; diff --git a/src/core/domains/express/utils/stripGuardedResourceProperties.ts b/src/core/domains/express/utils/stripGuardedResourceProperties.ts index 52bf82835..3aa25fa8d 100644 --- a/src/core/domains/express/utils/stripGuardedResourceProperties.ts +++ b/src/core/domains/express/utils/stripGuardedResourceProperties.ts @@ -1,7 +1,7 @@ import { IModel } from "@src/core/interfaces/IModel"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -const stripGuardedResourceProperties = (results: IModel[] | IModel) => { +const stripGuardedResourceProperties = (results: IModel[] | IModel) => { if(!Array.isArray(results)) { return results.getData({ excludeGuarded: true }) as IModel } diff --git a/src/core/domains/migrations/models/MigrationModel.ts b/src/core/domains/migrations/models/MigrationModel.ts index f12bf3621..a710bba65 100644 --- a/src/core/domains/migrations/models/MigrationModel.ts +++ b/src/core/domains/migrations/models/MigrationModel.ts @@ -1,10 +1,10 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; /** * Represents a migration stored in the database. */ -export interface MigrationModelData extends IModelData { +export interface MigrationModelData extends IModelAttributes { /** * The name of the migration. diff --git a/src/core/exceptions/UnexpectedAttributeError.ts b/src/core/exceptions/UnexpectedAttributeError.ts new file mode 100644 index 000000000..a46776227 --- /dev/null +++ b/src/core/exceptions/UnexpectedAttributeError.ts @@ -0,0 +1,8 @@ +export default class UnexpectedAttributeError extends Error { + + constructor(message: string = 'Unexpected attribute') { + super(message); + this.name = 'UnexpectedAttributeError'; + } + +} \ No newline at end of file diff --git a/src/core/interfaces/IModel.ts b/src/core/interfaces/IModel.ts index a6370c3ee..21b2d16e5 100644 --- a/src/core/interfaces/IModel.ts +++ b/src/core/interfaces/IModel.ts @@ -4,7 +4,7 @@ import { IBelongsToOptions } from "@src/core/domains/database/interfaces/relatio import { IHasManyOptions } from "@src/core/domains/database/interfaces/relationships/IHasMany"; import IWithObserve from "@src/core/domains/observer/interfaces/IWithObserve"; import { ICtor } from "@src/core/interfaces/ICtor"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; export type GetDataOptions = {excludeGuarded: boolean} @@ -40,13 +40,13 @@ export type ModelInstance> = InstanceType extends IWithObserve { +export interface IModel extends IWithObserve { connection: string; primaryKey: string; table: string; fields: string[]; guarded: string[]; - data: Data | null; + attributes: Attributes | null; dates: string[]; timestamps: boolean; json: string[]; @@ -54,12 +54,16 @@ export interface IModel extends IWithObser prepareDocument(): T; getDocumentManager(): IDocumentManager; getId(): string | undefined; - setAttribute(key: keyof Data, value: any): void; - getAttribute(key: keyof Data): any; + attr(key: K, value?: unknown): Attributes[K] | null | undefined + setAttribute(key: keyof Attributes, value: any): void; + getAttribute(key: keyof Attributes): any; + getOriginal(key: K): Attributes[K] | null + isDirty(): boolean; + getDirty(): Record | null setTimestamp(dateTimeField: string, value: Date): void; - fill(data: Partial): void; - getData(options: GetDataOptions): Data | null; - refresh(): Promise; + fill(data: Partial): void; + getData(options: GetDataOptions): Attributes | null; + refresh(): Promise; update(): Promise; save(): Promise; delete(): Promise; diff --git a/src/core/interfaces/IModelData.ts b/src/core/interfaces/IModelData.ts index af1829dca..09df8fe6f 100644 --- a/src/core/interfaces/IModelData.ts +++ b/src/core/interfaces/IModelData.ts @@ -6,7 +6,7 @@ * @property {Date} [updatedAt] - The date and time the model was updated. * @property {any} [key] - Any other property that is not explicitly defined. */ -export default interface IModelData { +export default interface IModelAttributes { id?: string; createdAt?: Date; updatedAt?: Date; diff --git a/src/tests/auth/auth.test.ts b/src/tests/auth/auth.test.ts index b42580b39..494e36571 100644 --- a/src/tests/auth/auth.test.ts +++ b/src/tests/auth/auth.test.ts @@ -129,7 +129,7 @@ describe('attempt to run app with normal appConfig', () => { apiToken && await App.container('auth').revokeToken(apiToken); await apiToken?.refresh(); - expect(apiToken?.data?.revokedAt).toBeTruthy(); + expect(apiToken?.attributes?.revokedAt).toBeTruthy(); }) }) \ No newline at end of file diff --git a/src/tests/events/eventQueue.test.ts b/src/tests/events/eventQueue.test.ts index 4aba828d6..65b792a34 100644 --- a/src/tests/events/eventQueue.test.ts +++ b/src/tests/events/eventQueue.test.ts @@ -76,7 +76,7 @@ describe('mock event service', () => { expect(movie?.getAttribute('name')).toBe(movieName); await movie?.delete(); - expect(movie?.data).toBeNull(); + expect(movie?.attributes).toBeNull(); }); diff --git a/src/tests/events/eventSync.test.ts b/src/tests/events/eventSync.test.ts index ca3ece723..83836c2d6 100644 --- a/src/tests/events/eventSync.test.ts +++ b/src/tests/events/eventSync.test.ts @@ -2,12 +2,11 @@ import { describe } from '@jest/globals'; import Kernel from '@src/core/Kernel'; import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; import TestSubscriber from '@src/tests/events/subscribers/TestSyncSubscriber'; import TestConsoleProvider from '@src/tests/providers/TestConsoleProvider'; import TestEventProvider from '@src/tests/providers/TestEventProvider'; -import testAppConfig from '../config/testConfig'; - describe('mock event service', () => { /** diff --git a/src/tests/models/modelDirty.test.ts b/src/tests/models/modelDirty.test.ts new file mode 100644 index 000000000..2e8e3eba7 --- /dev/null +++ b/src/tests/models/modelDirty.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Repository from '@src/core/base/Repository'; +import { IModel } from '@src/core/interfaces/IModel'; +import Kernel from '@src/core/Kernel'; +import { App } from '@src/core/services/App'; +import testAppConfig from '@src/tests/config/testConfig'; +import { getTestConnectionNames } from '@src/tests/config/testDatabaseConfig'; +import TestDirtyModel from '@src/tests/models/models/TestDirtyModel'; +import TestDatabaseProvider from '@src/tests/providers/TestDatabaseProvider'; +import { DataTypes } from 'sequelize'; + +const connections = getTestConnectionNames() + +const createTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + schema.createTable('tests', { + name: DataTypes.STRING, + object: DataTypes.JSON, + array: DataTypes.JSON, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) +} + +const dropTable = async (connectionName: string) => { + const schema = App.container('db').schema(connectionName) + + if(await schema.tableExists('tests')) { + await schema.dropTable('tests'); + } +} + +const truncate = async (connectionName: string) => { + await App.container('db').documentManager(connectionName).table('tests').truncate() +} + +describe('test dirty', () => { + + /** + * Boot the MongoDB provider + */ + beforeAll(async () => { + await Kernel.boot({ + ...testAppConfig, + providers: [ + ...testAppConfig.providers, + new TestDatabaseProvider() + ] + }, {}) + + for(const connection of connections) { + await dropTable(connection) + await createTable(connection) + } + }) + + test('dirty', async () => { + for(const connectionName of connections) { + App.container('logger').info('[Connection]', connectionName) + App.container('db').setDefaultConnectionName(connectionName); + + await truncate(connectionName); + + + /** + * Create author model + */ + const modelOne = new TestDirtyModel({ + name: 'John', + array: ['a', 'b'], + object: { + a: 1, + b: 1 + } + }) + expect(modelOne.isDirty()).toBeFalsy(); + + modelOne.attr('name', 'Jane') + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelOne.getOriginal('name') === 'John') + + modelOne.attr('array', ['a', 'b', 'c']) + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('array')).toBeTruthy() + expect((modelOne.getOriginal('array') as string[])?.length).toEqual(2) + + modelOne.attr('object', { + a: 2, + b: 2 + }) + expect(modelOne.isDirty()).toBeTruthy(); + expect(Object.keys(modelOne.getDirty() ?? {}).includes('object')).toBeTruthy() + expect((modelOne.getOriginal('object') as {a: number, b: number})?.a).toEqual(1) + expect((modelOne.getOriginal('object') as {a: number, b: number})?.b).toEqual(1) + + await modelOne.save(); + expect(modelOne.isDirty()).toBeFalsy(); + + modelOne.attr('name', 'Bob') + expect(modelOne.isDirty()).toBeTruthy() + expect(Object.keys(modelOne.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelOne.getOriginal('name') === 'Jane') + + await modelOne.delete(); + expect(modelOne.isDirty()).toBeFalsy(); + + await (new TestDirtyModel({ + name: 'John', + array: ['a', 'b'], + object: { + a: 1, + b: 1 + } + })).save() + const repository = new Repository(TestDirtyModel); + + const modelTwo = await repository.findOne({name: 'John'}) as IModel + expect(modelTwo).toBeTruthy() + expect(modelTwo).toBeInstanceOf(TestDirtyModel); + expect(modelTwo.isDirty()).toBeFalsy(); + + modelTwo.attr('name', 'Jane') + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('name')).toBeTruthy() + expect(modelTwo.getOriginal('name') === 'John') + + modelTwo.attr('array', ['a', 'b', 'c']) + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('array')).toBeTruthy() + expect((modelTwo.getAttribute('array') as string[])?.length).toEqual(3) + expect((modelTwo.getOriginal('array') as string[])?.length).toEqual(2) + + modelTwo.attr('object', { + a: 2, + b: 2 + }) + expect(modelTwo.isDirty()).toBeTruthy(); + expect(Object.keys(modelTwo.getDirty() ?? {}).includes('object')).toBeTruthy() + expect((modelTwo.getAttribute('object') as {a: number, b: number})?.a).toEqual(2) + expect((modelTwo.getAttribute('object') as {a: number, b: number})?.b).toEqual(2) + expect((modelTwo.getOriginal('object') as {a: number, b: number})?.a).toEqual(1) + expect((modelTwo.getOriginal('object') as {a: number, b: number})?.b).toEqual(1) + } + }) +}); \ No newline at end of file diff --git a/src/tests/models/modelHasMany.test.ts b/src/tests/models/modelHasMany.test.ts index e5ecd11cc..d6275999d 100644 --- a/src/tests/models/modelHasMany.test.ts +++ b/src/tests/models/modelHasMany.test.ts @@ -70,7 +70,7 @@ describe('test hasMany', () => { }) await authorModel.save(); expect(typeof authorModel.getId() === 'string').toBe(true) - expect(authorModel.data?.name).toEqual('John'); + expect(authorModel.attributes?.name).toEqual('John'); /** * Create movie model one and two @@ -82,8 +82,8 @@ describe('test hasMany', () => { }) await movieModelOne.save(); expect(typeof movieModelOne.getId() === 'string').toBe(true); - expect(movieModelOne.data?.name).toEqual('Movie One'); - expect(movieModelOne.data?.yearReleased).toEqual(1970); + expect(movieModelOne.attributes?.name).toEqual('Movie One'); + expect(movieModelOne.attributes?.yearReleased).toEqual(1970); const movieModelTwo = new TestMovieModel({ authorId: authorModel.getId()?.toString() as string, @@ -92,23 +92,23 @@ describe('test hasMany', () => { }) await movieModelTwo.save(); expect(typeof movieModelTwo.getId() === 'string').toBe(true); - expect(movieModelTwo.data?.name).toEqual('Movie Two'); - expect(movieModelTwo.data?.yearReleased).toEqual(1980); + expect(movieModelTwo.attributes?.name).toEqual('Movie Two'); + expect(movieModelTwo.attributes?.yearReleased).toEqual(1980); /** * Get related movies from author */ const relatedMovies = await authorModel.movies(); expect(relatedMovies.length).toEqual(2); - expect(relatedMovies.find((m) => m.data?.name === movieModelOne.data?.name)).toBeTruthy() - expect(relatedMovies.find((m) => m.data?.name === movieModelTwo.data?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelOne.attributes?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelTwo.attributes?.name)).toBeTruthy() /** * Get related movies from author from year 1970 */ const relatedMoviesWithFilters = await authorModel.moviesFromYear(1970); expect(relatedMoviesWithFilters.length).toEqual(1); - expect(relatedMovies.find((m) => m.data?.name === movieModelOne.data?.name)).toBeTruthy() + expect(relatedMovies.find((m) => m.attributes?.name === movieModelOne.attributes?.name)).toBeTruthy() } }) }); \ No newline at end of file diff --git a/src/tests/models/models/TestAuthor.ts b/src/tests/models/models/TestAuthor.ts index 309ae22de..3300df061 100644 --- a/src/tests/models/models/TestAuthor.ts +++ b/src/tests/models/models/TestAuthor.ts @@ -1,8 +1,8 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { TestMovieModel } from "@src/tests/models/models/TestMovie"; -export interface TestAuthorModelData extends IModelData { +export interface TestAuthorModelData extends IModelAttributes { name: string } export class TestAuthorModel extends Model { diff --git a/src/tests/models/models/TestDirtyModel.ts b/src/tests/models/models/TestDirtyModel.ts new file mode 100644 index 000000000..564ba8b7b --- /dev/null +++ b/src/tests/models/models/TestDirtyModel.ts @@ -0,0 +1,26 @@ +import Model from "@src/core/base/Model"; +import IModelAttributes from "@src/core/interfaces/IModelData"; + +interface TestDirtyModelAttributes extends IModelAttributes { + name: string, + array: string[], + object: object +} + +class TestDirtyModel extends Model { + + public table: string = 'tests'; + + public fields: string[] = [ + 'name', + 'array', + 'object', + 'createdAt', + 'updatedAt' + ] + + public json: string[] = ['array', 'object'] + +} + +export default TestDirtyModel \ No newline at end of file diff --git a/src/tests/models/models/TestModel.ts b/src/tests/models/models/TestModel.ts index a2bae1901..c657bd673 100644 --- a/src/tests/models/models/TestModel.ts +++ b/src/tests/models/models/TestModel.ts @@ -1,6 +1,7 @@ import Model from "@src/core/base/Model"; +import IModelAttributes from "@src/core/interfaces/IModelData"; -type TestModelData = { +interface TestModelData extends IModelAttributes { name: string } diff --git a/src/tests/models/models/TestMovie.ts b/src/tests/models/models/TestMovie.ts index 7ce61b0b4..561760353 100644 --- a/src/tests/models/models/TestMovie.ts +++ b/src/tests/models/models/TestMovie.ts @@ -1,8 +1,8 @@ import Model from "@src/core/base/Model"; -import IModelData from "@src/core/interfaces/IModelData"; +import IModelAttributes from "@src/core/interfaces/IModelData"; import { TestAuthorModel } from "@src/tests/models/models/TestAuthor"; -export interface TestMovieModelData extends IModelData { +export interface TestMovieModelData extends IModelAttributes { authorId?: string; name?: string; yearReleased?: number; From 29c88c40aebfba442b32c5b40e31d2b9a0368dec Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 13 Oct 2024 21:49:52 +0100 Subject: [PATCH 73/76] documentation(readme): Added write permissions to quick setup --- readme.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 7e265ef52..6e67db5fc 100644 --- a/readme.md +++ b/readme.md @@ -72,7 +72,17 @@ Follow these steps to quickly set up your project: This will install all the necessary dependencies for your project. -3. **Start Database Containers**: +3. **Add write permissions to logs directory** + + After installing dependencies, you need to add write permissions to the logs directory: + + ``` + chmod -R 755 /path/to/larascript/storage/logs + ``` + + This ensures that your application can write log files as needed. + +4. **Start Database Containers**: To set up your database environment, run: @@ -82,7 +92,7 @@ Follow these steps to quickly set up your project: This command will start the necessary database containers for your project. -4. **Run the setup command (optional)**: +5. **Run the setup command (optional)**: If you want to populate the .env file with configured settings, use: @@ -92,7 +102,7 @@ Follow these steps to quickly set up your project: This step is optional but can be helpful for quickly configuring your environment. -5. **Run database migrations**: +6. **Run database migrations**: To set up your database schema, run: @@ -102,7 +112,7 @@ Follow these steps to quickly set up your project: This command will apply all pending database migrations. -6. **Start developing**: +7. **Start developing**: To start your development server, use: From 8e8d560acd605a31760be5969c73b73128dad287 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 13 Oct 2024 22:46:20 +0100 Subject: [PATCH 74/76] fix(stripGuardedResourceProperties): fix ts complain --- .../domains/express/utils/stripGuardedResourceProperties.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/domains/express/utils/stripGuardedResourceProperties.ts b/src/core/domains/express/utils/stripGuardedResourceProperties.ts index 3aa25fa8d..1efa44ee1 100644 --- a/src/core/domains/express/utils/stripGuardedResourceProperties.ts +++ b/src/core/domains/express/utils/stripGuardedResourceProperties.ts @@ -3,10 +3,10 @@ import IModelAttributes from "@src/core/interfaces/IModelData"; const stripGuardedResourceProperties = (results: IModel[] | IModel) => { if(!Array.isArray(results)) { - return results.getData({ excludeGuarded: true }) as IModel + return results.getData({ excludeGuarded: true }) } - return results.map(result => result.getData({ excludeGuarded: true }) as IModel); + return results.map(result => result.getData({ excludeGuarded: true })); } export default stripGuardedResourceProperties \ No newline at end of file From 4dbf2229adc9d8d7cd1b546f80a5f13476e4aab3 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 13 Oct 2024 22:46:42 +0100 Subject: [PATCH 75/76] fix(resources): fixed missing delete resource logic --- .../express/services/Resources/ResourceDeleteService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/domains/express/services/Resources/ResourceDeleteService.ts b/src/core/domains/express/services/Resources/ResourceDeleteService.ts index 7a7e49c5e..0041d1add 100644 --- a/src/core/domains/express/services/Resources/ResourceDeleteService.ts +++ b/src/core/domains/express/services/Resources/ResourceDeleteService.ts @@ -44,6 +44,9 @@ class ResourceDeleteService extends BaseResourceService { throw new ForbiddenResourceError() } + // Delete the resource item + await result.delete() + // Send the results res.send({ success: true }) } From fa4def9ee64b5469ee72ef3747ff0471c27394e7 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 14 Oct 2024 19:42:51 +0100 Subject: [PATCH 76/76] updated banner --- assets/banner_black.png | Bin 56924 -> 0 bytes assets/banner_blank.png | Bin 0 -> 44056 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/banner_black.png create mode 100644 assets/banner_blank.png diff --git a/assets/banner_black.png b/assets/banner_black.png deleted file mode 100644 index cb5358d9d6ccc404c9ca0fa8f6dde2a58c0da005..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56924 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�lhWO$lZ3ui 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 J)+PPN{{yUmyg2{> diff --git a/assets/banner_blank.png b/assets/banner_blank.png new file mode 100644 index 0000000000000000000000000000000000000000..49ce06b77ff9585c483bf05a55c2f57482e2cd2c GIT binary patch literal 44056 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 literal 0 HcmV?d00001