diff --git a/src/app.module.ts b/src/app.module.ts index 5d12b5b..43e40f5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,5 @@ import { Module, Scope } from '@nestjs/common'; import { RefreshTokenInterceptor, SessionModule } from './core/sessions'; -import { AuthModule } from './auth/auth.module'; import { JWTGuard } from './core/guards'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { HttpExceptionFilter, TransformInterceptor } from './core/response'; @@ -9,9 +8,14 @@ import { CONNECTION_NAME } from './core/strings/constants/base.constants'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { UserPrivilegeModule } from './modules/user-related/user-privilege/user-privilege.module'; -import { UserPrivilegeModel } from './modules/user-related/user-privilege/data/model/user-privilege.model'; import { CqrsModule } from '@nestjs/cqrs'; import { CouchModule } from './modules/configuration/couch/couch.module'; +import { UserPrivilegeModels } from './modules/user-related/user-privilege/constants'; +import { RolesGuard } from './core/guards/domain/roles.guard'; +import { PrivilegeService } from './core/guards/domain/services/privilege.service'; +import { UserModel } from './modules/user-related/user/data/models/user.model'; +import { AuthModule } from './modules/configuration/auth/auth.module'; +import { UserModule } from './modules/user-related/user/user.module'; @Module({ imports: [ @@ -27,18 +31,20 @@ import { CouchModule } from './modules/configuration/couch/couch.module'; username: process.env.DEFAULT_DB_USER, password: process.env.DEFAULT_DB_PASS, database: process.env.DEFAULT_DB_NAME, - entities: [UserPrivilegeModel], - synchronize: true, + entities: [...UserPrivilegeModels, UserModel], + synchronize: false, }), CqrsModule, SessionModule, AuthModule, CouchModule, + UserModule, UserPrivilegeModule, ], controllers: [], providers: [ + PrivilegeService, /** * By default all request from client will protect by JWT * if there is some endpoint/function that does'nt require authentication @@ -47,7 +53,7 @@ import { CouchModule } from './modules/configuration/couch/couch.module'; { provide: APP_GUARD, scope: Scope.REQUEST, - useClass: JWTGuard, + useClass: RolesGuard, }, { provide: APP_INTERCEPTOR, diff --git a/src/core/guards/constants.ts b/src/core/guards/constants.ts index 602cdd9..7fc074f 100644 --- a/src/core/guards/constants.ts +++ b/src/core/guards/constants.ts @@ -1 +1,3 @@ export const UNPROTECTED_URL = 'unprotected_url'; +export const PRIVILEGE_KEY = 'privilege_key'; +export const MAIN_MENU = 'main_menu'; diff --git a/src/core/guards/domain/decorators/unprotected.guard.ts b/src/core/guards/domain/decorators/unprotected.guard.ts index 621e28c..6aa6697 100644 --- a/src/core/guards/domain/decorators/unprotected.guard.ts +++ b/src/core/guards/domain/decorators/unprotected.guard.ts @@ -1,7 +1,9 @@ import { SetMetadata } from '@nestjs/common'; -import { UNPROTECTED_URL } from '../../constants'; +import { MAIN_MENU, UNPROTECTED_URL } from '../../constants'; /** + * @deprecated + * Use Public instead * This decorator will exclude the request from token check * * NOTE: @@ -11,3 +13,9 @@ import { UNPROTECTED_URL } from '../../constants'; */ export const Unprotected = (isUnprotected = true) => SetMetadata(UNPROTECTED_URL, isUnprotected); + +export const Public = (isUnprotected = true) => + SetMetadata(UNPROTECTED_URL, isUnprotected); + +export const MainMenu = () => SetMetadata(MAIN_MENU, true); +export const ExcludePrivilege = () => SetMetadata(MAIN_MENU, true); diff --git a/src/core/guards/domain/jwt.guard.ts b/src/core/guards/domain/jwt.guard.ts index 8d98851..d02fba3 100644 --- a/src/core/guards/domain/jwt.guard.ts +++ b/src/core/guards/domain/jwt.guard.ts @@ -2,22 +2,25 @@ import { Injectable, CanActivate, ExecutionContext, - UnauthorizedException, Scope, Logger, + UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { SessionService, UsersSession } from 'src/core/sessions'; import { UNPROTECTED_URL } from '../constants'; +import { PrivilegeService } from './services/privilege.service'; @Injectable({ scope: Scope.REQUEST }) export class JWTGuard implements CanActivate { constructor( protected readonly session: SessionService, protected readonly reflector: Reflector, + protected readonly privilege: PrivilegeService, ) {} + protected isPublic = false; protected userSession: UsersSession; canActivate( @@ -31,6 +34,8 @@ export class JWTGuard implements CanActivate { UNPROTECTED_URL, [context.getHandler(), context.getClass()], ); + this.isPublic = isUnprotected; + this.session.setPublic(isUnprotected); if (isUnprotected) return true; /** diff --git a/src/core/guards/domain/roles.guard.ts b/src/core/guards/domain/roles.guard.ts index 28e3993..ab353fb 100644 --- a/src/core/guards/domain/roles.guard.ts +++ b/src/core/guards/domain/roles.guard.ts @@ -1,23 +1,36 @@ -import { Injectable, ExecutionContext } from '@nestjs/common'; -import { Observable } from 'rxjs'; +import { + Injectable, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; import { JWTGuard } from './jwt.guard'; +import { MAIN_MENU } from '../constants'; @Injectable() export class RolesGuard extends JWTGuard { - canActivate( - context: ExecutionContext, - ): boolean | Promise | Observable { + async canActivate(context: ExecutionContext): Promise { super.canActivate(context); - /** - * Create function to check if `this.userSession` have access - * to Read / Create / Update / and Other Action - */ + // jika endpoint tersebut bukan public, maka lakukan check lanjutan + if (!this.isPublic) { + // Check apakah endpoint ada decorator untuk exlude privilege (@ExcludePrivilege()) + const excludePrivilege = this.reflector.getAllAndOverride( + MAIN_MENU, + [context.getHandler(), context.getClass()], + ); + if (excludePrivilege) return true; + + // check apakah dapat akses module + const isNotAllow = await this.privilege.isNotAllowed(); + if (isNotAllow) { + throw new ForbiddenException({ + statusCode: 10003, + message: `Forbidden Access, you don't have access to this module!`, + error: 'ACCESS_FORBIDDEN', + }); + } + } - /** - * Assign rules to session, So Query can take the rules and give - * the data base on user request - */ return true; } } diff --git a/src/core/guards/domain/services/privilege.service.ts b/src/core/guards/domain/services/privilege.service.ts new file mode 100644 index 0000000..27f6809 --- /dev/null +++ b/src/core/guards/domain/services/privilege.service.ts @@ -0,0 +1,74 @@ +import { ForbiddenException, Inject, Injectable, Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { getAction } from 'src/core/helpers/path/get-action-from-path.helper'; +import { UserProvider } from 'src/core/sessions'; +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; +import { UserPrivilegeConfigurationModel } from 'src/modules/user-related/user-privilege/data/models/user-privilege-configuration.model'; +import { DataSource, IsNull } from 'typeorm'; +import { UserRole } from 'src/modules/user-related/user/constants'; +import { UserPrivilegeConfigurationEntity } from 'src/modules/user-related/user-privilege/domain/entities/user-privilege-configuration.entity'; + +@Injectable({ scope: Scope.REQUEST }) +export class PrivilegeService { + constructor( + @InjectDataSource(CONNECTION_NAME.DEFAULT) + protected readonly dataSource: DataSource, + + @Inject(REQUEST) private readonly request: Request, + protected readonly session: UserProvider, + ) {} + + get repository() { + return this.dataSource.getRepository(UserPrivilegeConfigurationModel); + } + + get user() { + return this.session.user; + } + + get action() { + const headerAction = this.request.headers['ex-model-action'] as string; + return headerAction ?? getAction(this.request.method, this.request.path); + } + + async isAllowed() { + // jika rolenya adalah superadmin, abaikan dan return true + if (this.user.role == UserRole.SUPERADMIN) return true; + + // check privilege dan sesuaikan dengan akse + const configurations = await this.privilegeConfiguration(); + return configurations[this.action]; + } + + async isNotAllowed() { + return !(await this.isAllowed()); + } + + private moduleKey() { + const headerKey = 'ex-model-key'; + const moduleKey = this.request.headers[headerKey] as string; + if (!moduleKey) { + throw new ForbiddenException({ + statusCode: 10005, + message: `Forbidden Access, access Module is Require!`, + error: 'MODULE_KEY_NOT_FOUND', + }); + } + const [module, menu, sub_menu, section] = moduleKey.split('.'); + return { module, menu, sub_menu, section }; + } + + async privilegeConfiguration(): Promise { + const { module, menu, sub_menu, section } = this.moduleKey(); + return await this.repository.findOne({ + select: ['id', 'view', 'create', 'edit', 'delete', 'cancel', 'confirm'], + where: { + user_privilege_id: this.user.user_privilege_id, + module: module, + menu: menu ?? IsNull(), + }, + }); + } +} diff --git a/src/core/helpers/path/get-action-from-path.helper.ts b/src/core/helpers/path/get-action-from-path.helper.ts new file mode 100644 index 0000000..67a04ca --- /dev/null +++ b/src/core/helpers/path/get-action-from-path.helper.ts @@ -0,0 +1,28 @@ +import { PrivilegeAction } from 'src/core/strings/constants/privilege.constants'; + +function containsUuid(str) { + const parts = str.split('/'); // Split the string by "/" + for (const part of parts) { + if ( + /^[0-9a-f]{8}\b-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + part, + ) + ) { + return true; + } + } + return false; +} + +export function getAction(method: string, path: string): string { + if (method === 'GET') return PrivilegeAction.VIEW; + else if (method === 'POST') return PrivilegeAction.CREATE; + else if (method === 'DELETE') return PrivilegeAction.DELETE; + else if (method === 'PATCH' || method === 'PUT') { + if (['confirm', 'active', 'inactive'].includes(path)) + return PrivilegeAction.CONFIRM; + else if (path.includes('cancel')) return PrivilegeAction.CANCEL; + else return PrivilegeAction.EDIT; + } + return 'forbidden'; +} diff --git a/src/core/sessions/domain/entities/user-sessions.interface.ts b/src/core/sessions/domain/entities/user-sessions.interface.ts index a4a4478..58004d5 100644 --- a/src/core/sessions/domain/entities/user-sessions.interface.ts +++ b/src/core/sessions/domain/entities/user-sessions.interface.ts @@ -1,4 +1,8 @@ +import { UserRole } from 'src/modules/user-related/user/constants'; + export interface UsersSession { id: number; name: string; + role: UserRole; + user_privilege_id: string; } diff --git a/src/core/sessions/domain/services/session.service.ts b/src/core/sessions/domain/services/session.service.ts index 59c2aa9..1b871e6 100644 --- a/src/core/sessions/domain/services/session.service.ts +++ b/src/core/sessions/domain/services/session.service.ts @@ -6,7 +6,23 @@ import { isTokenNearExpired } from '../utils/jwt.helpers'; @Injectable({ scope: Scope.REQUEST }) export class SessionService { + private public = false; + public ignorePrivilegeCondition = false; + constructor(private readonly jwt: JwtService) {} + + setPublic(value: boolean) { + this.public = value; + } + + get isPublic() { + return this.public; + } + + get isPrivate() { + return !this.isPublic; + } + createAccessToken(session: UsersSession): string { return this.jwt.sign(session); } diff --git a/src/core/sessions/session.module.ts b/src/core/sessions/session.module.ts index ed947c9..c4f0fa8 100644 --- a/src/core/sessions/session.module.ts +++ b/src/core/sessions/session.module.ts @@ -1,8 +1,8 @@ import { Global, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; -import { JWT_EXPIRED, JWT_SECRET } from '../../auth/constants'; import { UserProvider } from './domain/providers/user'; import { SessionService } from './domain/services/session.service'; +import { JWT_EXPIRED, JWT_SECRET } from './constants'; @Global() @Module({ diff --git a/src/core/strings/constants/base.constants.ts b/src/core/strings/constants/base.constants.ts index 272ddcd..e9edd83 100644 --- a/src/core/strings/constants/base.constants.ts +++ b/src/core/strings/constants/base.constants.ts @@ -42,4 +42,6 @@ export enum OPERATION { export const BLANK_USER = { id: null, name: null, + role: null, + user_privilege_id: null, }; diff --git a/src/core/strings/constants/privilege.constants.ts b/src/core/strings/constants/privilege.constants.ts new file mode 100644 index 0000000..26f3998 --- /dev/null +++ b/src/core/strings/constants/privilege.constants.ts @@ -0,0 +1,178 @@ +export enum PrivilegeAction { + VIEW = 'view', + CREATE = 'create', + EDIT = 'edit', + DELETE = 'delete', + CANCEL = 'cancel', + CONFIRM = 'confirm', +} + +export const PrivilegeAdminConstant = [ + { + menu: 'DASHBOARD', + menu_label: 'Dashboard', + actions: [PrivilegeAction.VIEW], + index: 1, + }, + { + menu: 'CALENDAR', + menu_label: 'Kalender', + actions: [PrivilegeAction.VIEW], + index: 2, + }, + { + menu: 'BOOKING', + menu_label: 'Pemesanan', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.CONFIRM, + PrivilegeAction.DELETE, + PrivilegeAction.CANCEL, + PrivilegeAction.EDIT, + ], + index: 3, + }, + { + menu: 'REFUND', + menu_label: 'Pengembalian', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.CONFIRM, + PrivilegeAction.DELETE, + PrivilegeAction.CANCEL, + PrivilegeAction.EDIT, + ], + index: 4, + }, + { + menu: 'REKONSILIASI', + menu_label: 'Rekonsiliasi', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.CONFIRM, + PrivilegeAction.DELETE, + PrivilegeAction.CANCEL, + PrivilegeAction.EDIT, + ], + index: 5, + }, + { + menu: 'ITEM', + menu_label: 'Item', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 6, + }, + { + menu: 'RATE_TYPE', + menu_label: 'Tipe Rate', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 7, + }, + { + menu: 'USER', + menu_label: 'Pengguna', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 8, + }, + { + menu: 'TENANT', + menu_label: 'Tenant', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 9, + }, + { + menu: 'WEB_INFORMATION', + menu_label: 'Informasi Web', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 10, + }, + { + menu: 'SETTING', + menu_label: 'Setting', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.EDIT, + PrivilegeAction.DELETE, + ], + index: 11, + }, + { + menu: 'LAPORAN', + menu_label: 'Laporan', + actions: [PrivilegeAction.VIEW], + index: 12, + }, + { + menu: 'DISKON_CODE', + menu_label: 'Generate Diskon Kode', + actions: [PrivilegeAction.CREATE], + index: 13, + }, +]; + +export const PrivilegePOSConstant = [ + { + menu: 'SALES', + menu_label: 'Penjualan', + actions: [ + PrivilegeAction.VIEW, + PrivilegeAction.CREATE, + PrivilegeAction.DELETE, + PrivilegeAction.EDIT, + ], + index: 14, + }, + { + menu: 'QR_PRINT', + menu_label: 'Print QR', + actions: [PrivilegeAction.VIEW, PrivilegeAction.CREATE], + index: 15, + }, + { + menu: 'BOOKING', + menu_label: 'Pemesanan', + actions: [PrivilegeAction.VIEW, PrivilegeAction.CREATE], + index: 16, + }, + { + menu: 'WITHDRAW', + menu_label: 'Penarikan Kas', + actions: [PrivilegeAction.VIEW, PrivilegeAction.CREATE], + index: 17, + }, + { + menu: 'POS_DISKON_CODE', + menu_label: 'Generate Diskon Kode', + actions: [PrivilegeAction.CREATE], + index: 18, + }, +]; diff --git a/src/modules/configuration/auth/infrastructure/auth.controller.ts b/src/modules/configuration/auth/infrastructure/auth.controller.ts index 559ba75..fda80f5 100644 --- a/src/modules/configuration/auth/infrastructure/auth.controller.ts +++ b/src/modules/configuration/auth/infrastructure/auth.controller.ts @@ -9,7 +9,7 @@ export class AuthController { constructor(private orchestrator: AuthOrchestrator) {} @Post() - @Public(false) + @Public(true) async login(@Body() body: LoginDto) { return await this.orchestrator.login(body); } diff --git a/src/modules/user-related/user-privilege/infrastructure/user-privilege-configuration.controller.ts b/src/modules/user-related/user-privilege/infrastructure/user-privilege-configuration.controller.ts index 681d9ac..8786f87 100644 --- a/src/modules/user-related/user-privilege/infrastructure/user-privilege-configuration.controller.ts +++ b/src/modules/user-related/user-privilege/infrastructure/user-privilege-configuration.controller.ts @@ -1,11 +1,10 @@ import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Public } from 'src/core/guards'; import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; import { UserPrivilegeConfigurationDataOrchestrator } from '../domain/usecases/user-privilege-configuration/user-privilege-configuration-data.orchestrator'; import { UserPrivilegeConfigurationDto } from './dto/user-privilege-configuration.dto'; import { UserPrivilegeConfigurationEntity } from '../domain/entities/user-privilege-configuration.entity'; -import { Pagination } from 'src/core/response'; import { FilterUserPrivilegeConfigurationDto } from './dto/filter-user-privilege-configuration.dto'; import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; @@ -14,6 +13,7 @@ import { PaginationResponse } from 'src/core/response/domain/ok-response.interfa ) @Controller(MODULE_NAME.USER_PRIVILEGE_CONFIGURATION) @Public(false) +@ApiBearerAuth('JWT') export class UserPrivilegeConfigurationController { constructor( private orchestrator: UserPrivilegeConfigurationDataOrchestrator, diff --git a/src/modules/user-related/user-privilege/infrastructure/user-privilege-data.controller.ts b/src/modules/user-related/user-privilege/infrastructure/user-privilege-data.controller.ts index 50c909c..b102f20 100644 --- a/src/modules/user-related/user-privilege/infrastructure/user-privilege-data.controller.ts +++ b/src/modules/user-related/user-privilege/infrastructure/user-privilege-data.controller.ts @@ -10,7 +10,7 @@ import { import { UserPrivilegeDataOrchestrator } from '../domain/usecases/user-privilege/user-privilege-data.orchestrator'; import { CreateUserPrivilegeDto } from './dto/create-user-privilege.dto'; import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { UserPrivilegeEntity } from '../domain/entities/user-privilege.entity'; import { BatchResult } from 'src/core/response/domain/ok-response.interface'; import { BatchIdsDto } from 'src/core/modules/infrastructure/dto/base-batch.dto'; @@ -19,6 +19,7 @@ import { Public } from 'src/core/guards'; @ApiTags(`${MODULE_NAME.USER_PRIVILEGE.split('-').join(' ')} - data`) @Controller(MODULE_NAME.USER_PRIVILEGE) @Public(false) +@ApiBearerAuth('JWT') export class UserPrivilegeDataController { constructor(private orchestrator: UserPrivilegeDataOrchestrator) {} diff --git a/src/modules/user-related/user-privilege/infrastructure/user-privilege-read.controller.ts b/src/modules/user-related/user-privilege/infrastructure/user-privilege-read.controller.ts index 92918f5..9b3fb4f 100644 --- a/src/modules/user-related/user-privilege/infrastructure/user-privilege-read.controller.ts +++ b/src/modules/user-related/user-privilege/infrastructure/user-privilege-read.controller.ts @@ -4,19 +4,19 @@ import { Pagination } from 'src/core/response'; import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; import { UserPrivilegeEntity } from '../domain/entities/user-privilege.entity'; import { UserPrivilegeReadOrchestrator } from '../domain/usecases/user-privilege/user-privilege-read.orchestrator'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; import { ExcludePrivilege, Public } from 'src/core/guards'; @ApiTags(`${MODULE_NAME.USER_PRIVILEGE.split('-').join(' ')} - read`) @Controller(MODULE_NAME.USER_PRIVILEGE) @Public(false) +@ApiBearerAuth('JWT') export class UserPrivilegeReadController { constructor(private orchestrator: UserPrivilegeReadOrchestrator) {} @Get() @Pagination() - @ExcludePrivilege() async index( @Query() params: FilterUserPrivilegeDto, ): Promise> {