commit 85efdb771409fbcafcc0664bf50320e5d8a7f3c3 Author: Jaka Ramdani Date: Tue Apr 21 06:25:33 2026 +0700 initial commit diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c857a26 --- /dev/null +++ b/README.md @@ -0,0 +1,445 @@ +# UTMS NG Backend (Spring Boot) + +Production-ready Spring Boot 3.x backend for user tenancy, RBAC, maker-checker workflow, module management, Redis caching/session support, ActiveMQ eventing, and i18n. + +## Table of Contents + +- [Project Overview](#project-overview) +- [Stack and Runtime Versions](#stack-and-runtime-versions) +- [High-Level Architecture](#high-level-architecture) +- [Repository and Package Layout](#repository-and-package-layout) +- [Getting Started](#getting-started) +- [Configuration](#configuration) +- [Security & AuthN/AuthZ](#security--authnauthz) +- [Multi-Tenancy](#multi-tenancy) +- [Workflow Engine](#workflow-engine) +- [Module System](#module-system) +- [API Documentation](#api-documentation) +- [Eventing and Messaging](#eventing-and-messaging) +- [Persistence Model](#persistence-model) +- [i18n and Error Handling](#i18n-and-error-handling) +- [Observability](#observability) +- [Sequence Diagrams](#sequence-diagrams) +- [Useful Commands](#useful-commands) +- [Contributing Notes](#contributing-notes) + +## Project Overview + +The system is organized into modular packages and service layers: + +- `api`: REST-facing controllers. +- `auth`: authentication, authorization, JWT, LDAP integration, token handling, and user-role primitives. +- `core`: cross-cutting concerns (errors, base entities, audit, caching, i18n, DB config). +- `tenant`: tenant context and tenant isolation filters. +- `workflow`: maker-checker workflow engine. +- `module`: pluggable feature module registry. +- `messaging`: ActiveMQ producer/consumer for async post-approval actions. + +## Stack and Runtime Versions + +The project runs on Java 17 and uses Spring Boot starter dependencies. + +- Java 17+ +- Spring Boot 3.3.5 +- Spring Security +- Spring Data JPA + Hibernate +- PostgreSQL +- Redis +- ActiveMQ +- Maven +- SpringDoc OpenAPI + +Main build and dependency file: +- [pom.xml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/pom.xml) + +## High-Level Architecture + +The application follows request/response flow with cross-cutting servlet filters: + +1) `TenantFilter` resolves tenant ID from `X-Tenant-Id`. +2) `JwtAuthenticationFilter` authenticates bearer tokens when present. +3) Method-level security checks RBAC/permission requirements. +4) Business services apply transaction boundaries and audit/logging. +5) Workflow events are published to ActiveMQ on approval completion. + +Core architectural file references: + +- [application entrypoint](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/UtmsNgBeApplication.java) +- [security config](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/config/SecurityConfig.java) +- [openapi config](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/config/OpenApiConfig.java) +- [tenant context filter](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantFilter.java) +- [tenant hibernate filter](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantHibernateFilter.java) +- [tenant entity listener](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/domain/TenantEntityListener.java) +- [base entity and auditing](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/domain/BaseEntity.java) +- [audit trail](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/audit/domain/AuditTrail.java) + +## Repository and Package Layout + +Top-level structure: + +- `src/main/java/id/iptek/utms` +- `src/main/resources` +- `docs` (documentation) +- `docker-compose.yml` +- `pom.xml` + +Important package-level references: + +- [api controllers](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/api) +- [auth module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth) +- [core module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core) +- [tenant module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant) +- [workflow module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow) +- [module system](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module) +- [messaging](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/messaging) + +## Getting Started + +### Prerequisites + +- JDK 17 +- PostgreSQL 16 +- Redis 7+ +- ActiveMQ 5.18+ +- Maven +- PowerShell (for local commands in this environment) + +### Local run with Docker + +1) Start infrastructure: + +```shell +docker compose up -d +``` + +2) Build the application image and run as in compose: + +```shell +docker compose up --build -d +``` + +3) Access services: + +- Backend: `http://localhost:9191` +- Swagger UI: `http://localhost:9191/swagger-ui.html` +- API docs: `http://localhost:9191/v3/api-docs` +- Postgres: `localhost:5432` +- Redis: `localhost:6379` +- ActiveMQ admin: `http://localhost:8161` + +## Configuration + +Profiles are defined in: + +- [application.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application.yml) +- [application-dev.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application-dev.yml) +- [application-prd.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application-prd.yml) +- [application-local.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application-local.yml) +- [Docker compose](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/docker-compose.yml) + +Common config sections: + +- datasource: PostgreSQL connection +- data.redis: Redis host/port and cache config +- activemq: broker and credentials +- app.ldap: optional LDAP integration (disabled by default) +- app.security.login.*: login brute-force thresholds +- app.seed.enabled: bootstrap sample data flag + +Brute-force defaults for login: + +- max failed attempts: `app.security.login.max-failed-attempts` +- attempt window (seconds): `app.security.login.failed-attempt-window-seconds` +- lockout window (seconds): `app.security.login.lockout-duration-seconds` + +Single-session login option: +- `app.security.single-login.enabled` (default `false`) + - `true` = user can only have one active session at a time; new login invalidates previous access/refresh session. + +## Security & AuthN/AuthZ + +### Login and JWT + +- login endpoint: `POST /api/auth/login` +- refresh endpoint: `POST /api/auth/refresh` +- logout endpoint: `POST /api/auth/logout` +- JWT utility: [JwtService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/security/JwtService.java) +- JWT principal adapter: [UserPrincipal](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/security/UserPrincipal.java) +- token filter: [JwtAuthenticationFilter](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/security/JwtAuthenticationFilter.java) +- auth service: [AuthService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/service/AuthService.java) +- rate limit lockout service: [LoginThrottleService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/service/LoginThrottleService.java) +- token blacklist: [TokenBlacklistService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/service/TokenBlacklistService.java) +- refresh token entity: [RefreshToken](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/domain/RefreshToken.java) + +### RBAC model + +- Roles are prefixed with `ROLE_` in authorities via `UserPrincipal`. +- Permissions are loaded from `Role -> Permission` and exposed as authorities directly. +- Default protected role/permission checks use `@PreAuthorize`. +- Common checks: +- `hasRole('ADMIN')` +- `hasAuthority('WORKFLOW_APPROVE')` +- `hasAuthority('USER_MANAGE')` +- `hasAuthority('ROLE_MANAGE')` + +### Optional LDAP + +LDAP can be enabled without code changes using profile configuration. + +- `app.ldap.enabled=true` to switch it on. +- LDAP-specific properties are in [application.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application.yml). +- Provider wiring is in [LdapAuthConfig](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java). + +## Multi-Tenancy + +Tenant is always provided by: + +- `X-Tenant-Id` header via [TenantFilter](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantFilter.java) +- JWT claim `tenant` via [JwtService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/security/JwtService.java) + +Tenant context is thread-bound: +- set by `TenantContext` and used by services and entities. + +Tenant isolation strategy: +- Hibernate `tenantFilter` is defined on tenant-scoped entities. +- [TenantHibernateFilter](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantHibernateFilter.java) enables filter per request. +- `BaseEntity` holds `tenant_id` for all shared tables. +- `TenantService` validates active tenant with cache. + +Tenant validation for active tenants: +- [TenantService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantService.java) +- [TenantRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/TenantRepository.java) + +## Workflow Engine + +Workflow is required for user/role management operations and available as a first-class service. + +- workflow service: [ApprovalWorkflowService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/service/ApprovalWorkflowService.java) +- approval controller: [ApprovalWorkflowController](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/controller/ApprovalWorkflowController.java) +- workflow entities: +- [ApprovalRequest](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalRequest.java) +- [ApprovalStep](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalStep.java) +- [ApprovalHistory](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalHistory.java) +- approval DTOs: [CreateApprovalRequest](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/dto/CreateApprovalRequest.java), [ApprovalActionRequest](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/dto/ApprovalActionRequest.java), [ApprovalResponse](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/dto/ApprovalResponse.java) + +Workflow state progression: + +- Maker creates request with required steps. +- Checker role per step enforces which role can approve. +- Each step is persisted as `PENDING`. +- Approve action updates step and request progress. +- Reject action sets request to `REJECTED`. +- Final approval publishes `ApprovalCompletedEvent` to ActiveMQ and user-role changes are applied by consumer. + +## Module System + +- module domain: [SystemModule](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/domain/SystemModule.java) +- module registry service: [ModuleRegistryService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/service/ModuleRegistryService.java) +- module contract: [Module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/service/Module.java) +- sample module: [NotificationModule](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/service/NotificationModule.java) + +Module operations: +- list: `GET /api/modules` +- toggle by code: `POST /api/modules/{code}/toggle` + +## API Documentation + +Swagger/OpenAPI: +- UI: `http://localhost:9191/swagger-ui.html` +- JSON spec: `http://localhost:9191/v3/api-docs` +- openapi settings: [OpenApiConfig](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/config/OpenApiConfig.java) + +### Endpoints + +Auth endpoints: +- POST `/api/auth/login` +- POST `/api/auth/refresh` +- POST `/api/auth/logout` + +Tenant endpoint: +- GET `/api/tenant/context` + +User endpoint: +- GET `/api/users/me` +- POST `/api/users/management/requests/create` +- POST `/api/users/management/requests/update-roles` + +Role endpoints: +- POST `/api/roles/management/requests/create` +- POST `/api/roles/management/requests/update-permissions` + +Workflow endpoints: +- POST `/api/workflow/request` +- POST `/api/workflow/{id}/approve` +- POST `/api/workflow/{id}/reject` + +Module endpoints: +- GET `/api/modules` +- POST `/api/modules/{code}/toggle` + +Audit endpoints: +- GET `/api/audit?limit=50` + +Health endpoint: +- GET `/actuator/health` + +Swagger-safe quick sample JSON: + +```json +{ + "username": "maker", + "password": "Passw0rd!" +} +``` + +```json +{ + "resourceType": "USER_MANAGEMENT", + "resourceId": "sample-user", + "payload": "{\"operation\":\"CREATE_USER\",\"username\":\"alice\"}", + "requiredSteps": 1 +} +``` + +```json +{ + "username": "admin", + "roleCodes": ["ADMIN"] +} +``` + +### Mandatory headers + +- Tenant: +- `X-Tenant-Id: acme` +- Locale: +- `Accept-Language: en-US` or `id-ID` +- Authorization: +- `Authorization: Bearer ` + +## Eventing and Messaging + +ActiveMQ integration: +- producer: [ApprovalEventProducer](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/messaging/ApprovalEventProducer.java) +- consumer: [ApprovalEventConsumer](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/messaging/ApprovalEventConsumer.java) +- event payload: [ApprovalCompletedEvent](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/messaging/ApprovalCompletedEvent.java) +- queue name: `approval.completed.queue` + +Post-approval process: +- event published when an approval request reaches `APPROVED`. +- consumer invokes [UserRoleManagementService.applyApprovedRequest](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/service/UserRoleManagementService.java). + +## Persistence Model + +### Naming and schema strategy + +- security tables use `sec_` prefix. +- workflow/system/audit tables use `sys_` prefix. +- This is maintained in all entities and confirmed in the schema file. + +Database schema reference: +- [schema.sql](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/db/schema.sql) + +Core entities: + +- Tenant: [Tenant](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/tenant/Tenant.java) +- RBAC: +- User: [User](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/domain/User.java) +- Role: [Role](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/domain/Role.java) +- Permission: [Permission](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/domain/Permission.java) +- Refresh token: [RefreshToken](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/domain/RefreshToken.java) +- Workflow: +- ApprovalRequest: [ApprovalRequest](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalRequest.java) +- ApprovalStep: [ApprovalStep](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalStep.java) +- ApprovalHistory: [ApprovalHistory](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/domain/ApprovalHistory.java) +- Module: [SystemModule](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/domain/SystemModule.java) +- Audit: [AuditTrail](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/audit/domain/AuditTrail.java) + +### Repositories + +- [PermissionRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/repository/PermissionRepository.java) +- [RoleRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/repository/RoleRepository.java) +- [UserRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/repository/UserRepository.java) +- [RefreshTokenRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/auth/repository/RefreshTokenRepository.java) +- [AuditTrailRepository](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/audit/repository/AuditTrailRepository.java) +- [Workflow repositories](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/workflow/repository) + +## i18n and Error Handling + +Message bundles: +- default (en_US): [messages.properties](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/i18n/messages.properties) +- indonesia locale (id_ID): [messages_id.properties](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/i18n/messages_id.properties) + +Message resolution helper: +- [MessageResolver](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/i18n/MessageResolver.java) + +Global errors: +- [GlobalExceptionHandler](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/exception/GlobalExceptionHandler.java) +- app exception model: +- [AppException](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/exception/AppException.java) + +## Observability + +- Actuator: +- health/info endpoints via config in [application.yml](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/resources/application.yml) +- Audit logger: +- [AuditLoggingAspect](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/config/AuditLoggingAspect.java) +- persistent audit trail records: +- [AuditTrailService](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/audit/service/AuditTrailService.java) +- [Audit API](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/api/AuditController.java) + +## Sequence Diagrams + +Full controller interaction diagrams are available in: + +- [docs/sequence-diagrams.md](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/docs/sequence-diagrams.md) + +## Useful Commands + +Build only: + +```shell +mvn -q -DskipTests compile +``` + +Run tests: + +```shell +mvn test +``` + +Run locally (default profile): + +```shell +$env:SPRING_PROFILES_ACTIVE="local"; mvn spring-boot:run +``` + +Run dev profile: + +```shell +$env:SPRING_PROFILES_ACTIVE="dev"; mvn spring-boot:run +``` + +Run prd profile: + +```shell +$env:SPRING_PROFILES_ACTIVE="prd"; mvn spring-boot:run +``` + +## Contributing Notes + +Data seeding: +- enabled in `dev` and `local` via profile/setting. +- seed source: [DataSeeder](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/core/config/DataSeeder.java). +- to add bootstrap users/roles/permissions, modify this component intentionally. + +Extending modules: +- implement [Module](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/src/main/java/id/iptek/utms/module/service/Module.java) +- register as Spring component +- expose behavior in toggle handlers. + +Extending API: +- add DTOs under `auth|workflow|module|api` +- add service under domain package +- create controller endpoint and secure with `@PreAuthorize` +- update docs in this file and [docs/sequence-diagrams.md](/D:/Projects/Personal/IPTEK/utms-ng/utms-ng-be/docs/sequence-diagrams.md). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd0b97c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +services: + utms-ng-be: + build: + context: . + image: utms-ng-be:local + container_name: utms-ng-be + depends_on: + - postgres + - redis + - activemq + profiles: + - local + environment: + SPRING_PROFILES_ACTIVE: local + DB_URL: jdbc:postgresql://postgres:5432/utmsng + DB_USERNAME: utms + DB_PASSWORD: utms + REDIS_HOST: redis + REDIS_PORT: 6379 + ACTIVEMQ_BROKER_URL: tcp://activemq:61616 + ACTIVEMQ_USER: admin + ACTIVEMQ_PASSWORD: admin + JWT_SECRET: local-dev-fallback-jwt-secret-key-for-local-dev-environment-256-bits-min + ports: + - "9191:9191" + + postgres: + image: postgres:16 + container_name: utms-postgres + environment: + POSTGRES_DB: utmsng + POSTGRES_USER: utms + POSTGRES_PASSWORD: utms + ports: + - "5432:5432" + volumes: + - pg_data:/var/lib/postgresql/data + + redis: + image: redis:7 + container_name: utms-redis + ports: + - "6379:6379" + + activemq: + image: symptoma/activemq:5.18.3 + container_name: utms-activemq + environment: + ACTIVEMQ_ADMIN_LOGIN: admin + ACTIVEMQ_ADMIN_PASSWORD: admin + ports: + - "61616:61616" + - "8161:8161" + +volumes: + pg_data: diff --git a/docs/frontend-api-surface.md b/docs/frontend-api-surface.md new file mode 100644 index 0000000..47072cc --- /dev/null +++ b/docs/frontend-api-surface.md @@ -0,0 +1,335 @@ +# Frontend API Surface (Backend: Current Spring Boot) + +Use this as the exact frontend integration reference for the existing backend. + +## 1) API Envelope + +Most responses use: + +```ts +type ApiResponse = { + success: boolean + message: string + data: T + timestamp: string +} +``` + +### Error policy (actual backend behavior) +- Business errors and validation in request payloads return HTTP `400` with: + - `{ success: false, message: "...", data: null, timestamp: "..." }` +- Authorization failures return HTTP `403`: + - `{ success: false, message: "Access denied", data: null, timestamp: "..." }` +- Unhandled internal exceptions return HTTP `500` with: + - `{ success: false, message: "Internal server error", data: null, timestamp: "..." }` +- Authentication failures from Spring Security typically return `401` and are handled by frontend interceptor. +- JWT/session validation/blacklist failures can return `401` or be handled by security filters before controller. + +## 2) Global request headers + +For **every protected request** after login: +- `Authorization: Bearer ` +- `X-Tenant-Id: ` +- Optional: `Accept-Language: en-US` or `id-ID` + +`POST /api/auth/login` also requires: +- `X-Tenant-Id` + +## 3) Auth APIs + +### POST `/api/auth/login` + +Request: + +```ts +{ username: string; password: string } +``` + +Success (`200`): + +```ts +{ + "success": true, + "message": "Login successful", + "data": { + "tokenType": "Bearer", + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "...", + "expiresInSeconds": 900 + } +} +``` + +Common login failures: +- `401`: invalid credentials from security layer +- `400`: `{ message: "Invalid username or password" }` +- `400`: `{ message: "Account locked. Please try again in {0} seconds" }` +- LDAP mode + not provisioned local tenant user: `{ message: "LDAP user authenticated but not provisioned in this tenant" }` + +### POST `/api/auth/refresh` + +Request: + +```ts +{ refreshToken: string } +``` + +Success: + +```ts +{ + "success": true, + "message": "Token refreshed successfully", + "data": { + "tokenType": "Bearer", + "accessToken": "...", + "refreshToken": "...", + "expiresInSeconds": 900 + } +} +``` + +Failure: +- `400` with message `Refresh token not found` / `Token expired or revoked` + +### POST `/api/auth/logout` + +Request headers: +- `Authorization` and `X-Tenant-Id` +- optional body + +Success: + +```ts +{ "success": true, "message": "Logout successful", "data": null } +``` + +## 4) Profile API + +### GET `/api/users/me` + +Success: + +```ts +{ + "success": true, + "message": "Current user fetched successfully", + "data": { + "tenantId": "acme", + "username": "alice", + "roles": ["ADMIN", "USER_ROLE_ADMIN"], + "permissions": ["USER_MANAGE", "WORKFLOW_APPROVE", "ROLE_MANAGE"] + } +} +``` + +Use `roles` and `permissions` for: +- menu visibility +- action visibility +- route guards + +## 5) Tenant APIs + +### GET `/api/tenant/context` + +```ts +{ tenantId: "acme" } +``` + +## 6) User Management APIs (Workflow-first) + +### POST `/api/users/management/requests/create` + +#### Local mode (default) +```ts +{ + username: string, + password: string, // required local mode + enabled?: boolean, + roleCodes: string[] +} +``` + +#### LDAP mode +```ts +{ + username: string, + ldapDn?: string, // optional metadata + enabled?: boolean, + roleCodes: string[] +} +``` + +Success always returns workflow request: + +```ts +{ + "success": true, + "message": "User management request created", + "data": { + "id": "uuid", + "resourceType": "USER_MANAGEMENT", + "resourceId": "jane", + "status": "PENDING", + "requiredSteps": 1, + "currentStep": 0 + } +} +``` + +### POST `/api/users/management/requests/update-roles` + +```ts +{ username: string; roleCodes: string[] } +``` + +Returns same response shape as approval response. + +## 7) Role Management APIs (Workflow-first) + +### POST `/api/roles/management/requests/create` + +```ts +{ code: string; name: string; permissionCodes: string[] } +``` + +### POST `/api/roles/management/requests/update-permissions` + +```ts +{ code: string; permissionCodes: string[] } +``` + +Both return `ApprovalResponse` with request status PENDING. + +## 8) Workflow APIs + +### POST `/api/workflow/request` + +Generic endpoint for custom workflow request: + +```ts +{ + resourceType: string, + resourceId: string, + payload: string, + requiredSteps: number +} +``` + +### GET `/api/workflow/requests` + +Query params: +- `status` = `DRAFT|PENDING|APPROVED|REJECTED` (optional) +- `resourceType` (optional) +- `makerUsername` (optional) +- `limit` default `50`, max internally clamped to `200` + +Response list item: + +```ts +{ + id: "uuid", + tenantId: "acme", + resourceType: "USER_MANAGEMENT", + resourceId: "jane", + makerUsername: "alice", + payload: "{\"operation\":\"CREATE_USER\",...}", + status: "PENDING", + requiredSteps: 1, + currentStep: 0, + createdAt: "2026-04-20T08:00:00Z", + updatedAt: "2026-04-20T08:00:00Z" +} +``` + +### POST `/api/workflow/{id}/approve` + +```ts +{ notes?: string; checkerRole?: string } +``` + +- If `checkerRole` omitted, backend uses step role default (`CHECKER` unless overridden by system default). +- Maker cannot approve own request. +- Success returns updated `ApprovalResponse`. + +### POST `/api/workflow/{id}/reject` + +```ts +{ notes?: string; checkerRole?: string } +``` + +Same behavior and guards as approve. + +## 9) Module APIs (Admin only) + +### GET `/api/modules` + +Returns: + +```ts +{ code: string; name: string; enabled: boolean }[] +``` + +### POST `/api/modules/{code}/toggle` + +```ts +{ enabled: boolean } +``` + +Requires admin role. + +## 10) Audit APIs (Admin only) + +### GET `/api/audit?limit=50` + +Response items include at least: +- `id`, `tenantId`, `actor`, `correlationId`, `action`, `domain`, `resourceType`, `resourceId`, `outcome`, `httpMethod`, `requestPath`, `beforeState`, `afterState`, `details`, `createdAt`. + +Used for security/auditor trail and troubleshooting. + +## 11) Statuses and RBAC to gate UI + +- Approval status from backend: `DRAFT`, `PENDING`, `APPROVED`, `REJECTED`. +- User create / update role actions: `USER_MANAGE` OR `USER_ROLE_ADMIN` +- Role create / update permissions: `ROLE_MANAGE` OR `USER_ROLE_ADMIN` +- Workflow approve/reject: `WORKFLOW_APPROVE` OR `CHECKER` +- Workflow list: `WORKFLOW_APPROVE` OR `CHECKER` OR `ADMIN` +- Audit & modules listing/toggle: `ADMIN` +- Profile (`/api/users/me`): `USER_READ` OR `ADMIN` + +## 12) Frontend negative-path checklist (QA-ready) + +1. Login without tenant header should fail on protected flows. +2. Login with valid credentials but wrong tenant should fail on tenant-dependent services. +3. Repeated wrong password: + - eventually returns lockout message after configured threshold. +4. Create user (local mode) without password -> shows localized required validation error. +5. Create user (LDAP mode) with password payload should still be accepted by UI only if intentionally sent; backend ignores and should not rely on it. +6. Create user request duplicate username returns `400 User already exists`. +7. Workflow approve/reject where maker == checker returns error message `Maker cannot approve own request`. +8. Approving/rejecting without proper role returns `403`. +9. Audit API called by non-admin should return `403`. +10. Refresh with invalid token returns `400` and clear token state. + +## 13) Suggested QA smoke script + +- Validate auth: + - login, refresh, me, logout +- Validate tenant: + - switch tenant header and ensure data partitions by tenant +- Validate management flow: + - create user (local/LDAP variant) -> should appear in workflow as `PENDING` + - role create -> approval created + - approve/reject -> state transition to `APPROVED/REJECTED` +- Validate guard: + - hide actions by permissions and re-check with token from restricted user + +## 14) Setup checklist + +- Create `.env.local`: + - `NEXT_PUBLIC_API_BASE_URL=http://localhost:9191` + - `NEXT_PUBLIC_DEFAULT_TENANT=acme` + - `NEXT_PUBLIC_LOCALE=en` +- npm install / pnpm install +- Add Axios interceptor for auth and tenant headers +- Add 401/403 interceptor handling for logout and route redirect diff --git a/docs/frontend-initial-prompt.md b/docs/frontend-initial-prompt.md new file mode 100644 index 0000000..08f1880 --- /dev/null +++ b/docs/frontend-initial-prompt.md @@ -0,0 +1,299 @@ +You are a senior frontend engineer. Generate a **production-ready Next.js (App Router) admin dashboard** using **Tabler UI** as the design system. + +Use this prompt as the current source of truth for backend behavior. + +## Backend Summary (Current) + +- Base URL: `http://` (Swagger: `/swagger-ui.html`, OpenAPI: `/v3/api-docs`) +- Security: JWT (Bearer token) +- Multi-tenancy: required via header `X-Tenant-Id` +- Optional LDAP mode: `app.ldap.enabled` (backend switch) +- API pattern: most state-changing endpoints are workflow-driven approvals +- Default responses use: + +```ts +type ApiResponse = { + success: boolean + message: string + data: T + timestamp: string +} +``` + +## Tech Stack + +- Next.js (App Router) +- React 18+ +- TypeScript +- Tabler UI (CSS/React components) +- Axios +- Zustand (recommended) +- Tailwind (optional only for utility overrides) +- react-intl or next-intl for i18n + +## Recommended Project Structure + +- `app/` + - `(auth)/login/page.tsx` + - `(dashboard)/layout.tsx` + - `(dashboard)/page.tsx` + - `api-proxy/` or service barrel exports +- `components/` + - `layout/` (DashboardShell, Sidebar, Header) + - `ui/` (Table, Form, Modal, Alert, Badge, Drawer) + - `workflow/` (ApprovalTable, StatusBadge, ApprovalActionModal) + - `user/` (UserForm, UpdateRolesForm) + - `role/` (RoleForm, RolePermissionForm) +- `services/` + - `api.ts` + - `auth.ts` + - `users.ts` + - `workflow.ts` + - `tenant.ts` + - `audit.ts` +- `store/` + - `authStore.ts` + - `uiStore.ts` + - `tenantStore.ts` + - `permissionStore.ts` +- `hooks/` + - `useAuth.ts`, `useTenantHeader.ts`, `useApi.ts`, `usePermissions.ts` +- `types/` + - API contracts and DTO types + +## API Endpoints to Use (exact) + +### Auth +- `POST /api/auth/login` +- `POST /api/auth/refresh` +- `POST /api/auth/logout` + +### Authenticated profile +- `GET /api/users/me` + +### User management (workflow requests) +- `POST /api/users/management/requests/create` +- `POST /api/users/management/requests/update-roles` + +### Role management (workflow requests) +- `POST /api/roles/management/requests/create` +- `POST /api/roles/management/requests/update-permissions` + +### Workflow +- `POST /api/workflow/request` +- `POST /api/workflow/{id}/approve` +- `POST /api/workflow/{id}/reject` +- `GET /api/workflow/requests?status=PENDING&resourceType=...&makerUsername=...&limit=50` + +### Modules +- `GET /api/modules` +- `POST /api/modules/{code}/toggle` + +### Tenant & audit +- `GET /api/tenant/context` +- `GET /api/audit?limit=50` + +## Authentication and request headers + +For **every request** after login: +- `Authorization: Bearer ` +- `X-Tenant-Id: ` + +Login request also requires tenant context because backend resolves tenant at auth time: +- `POST /api/auth/login` with header `X-Tenant-Id` + +Logout request behavior: +- `POST /api/auth/logout` requires a valid Bearer token because backend invalidates/revokes refresh/session context. + +Optional: +- `Accept-Language: en-US` or `id-ID` + +## JWT/session behavior + +- Access token in response includes `tokenType: "Bearer"`, `accessToken`, `refreshToken`, `expiresIn` +- Store tokens in secure storage strategy (HTTP-only cookies preferred if possible; otherwise memory + storage hardening) +- Add request interceptor to attach token and `X-Tenant-Id` +- Add response interceptor for 401: + - clear auth state + - redirect to login + - keep tenant and locale selections persisted + +## Important authorization model + +Backend sends authorities as roles/permissions: +- Roles come as `ROLE_` (from DB role code) +- Permissions come as plain `...` codes +- Controllers currently check: + - User create/update-roles: `hasAuthority('USER_MANAGE') or hasRole('USER_ROLE_ADMIN')` + - Role create/update-permissions: `hasAuthority('ROLE_MANAGE') or hasRole('USER_ROLE_ADMIN')` + - Create workflow request: `hasAuthority('WORKFLOW_CREATE') or hasRole('MAKER')` + - Approve/reject: `hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER')` + - Workflow list: `hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER') or hasRole('ADMIN')` + - `/api/audit`: `hasRole('ADMIN')` + - `/api/users/me`: `hasAuthority('USER_READ') or hasRole('ADMIN')` + +So frontend should render actions conditionally using permissions derived from `/api/users/me`. + +## LDAP mode alignment + +Backend has optional LDAP mode (`app.ldap.enabled`). + +- **Local mode** + - `/api/users/management/requests/create` requires `password` +- **LDAP mode** + - Password is managed in directory (backend does not require password for user provisioning) + - `password` should not be sent for user creation + - optional `ldapDn` may be included +- Common for both modes + - user update roles still workflow-driven + - role create/update-permissions still workflow-driven + - no direct mutation endpoints for user/role entities + +## Required front-end behavior by page + +### 1) Login page +- Input: username, password, tenant selector +- Submit `POST /api/auth/login` +- Pass `X-Tenant-Id` header +- Handle error responses from backend localization keys and lockout messages + +### 2) Dashboard shell +- Sidebar: Dashboard, Users, Roles, Workflow, Audit, Modules, Settings +- Top bar: tenant selector, locale switch, user menu/logout +- Display auth mode indicator (Local / LDAP) when available + +### 3) Dashboard home +- Show summary cards: + - pending workflow count + - pending checker workload (from `/api/workflow/requests?status=PENDING`) + - audit/approval health snapshots (from `/api/audit?limit=50`) + - recent audits (from /api/audit) + +### 4) Users page +- There is no direct `/api/users` list endpoint in current backend, so derive list/context from workflow/request history and `/api/users/me` context. +- Actions: + - create user request (workflow) + - update user roles request (workflow) +- In LDAP mode hide password input on create form +- In local mode enforce password validation before submit + +### 5) Roles page +- No direct role list endpoint exists in current backend; show role/permission operations using current user context and workflow history as available. +- Implement create role request + permission update request flows. +- Permission selector from current in-app permission catalog (from `/api/users/me`, seeded defaults, and known workflow operations). + +### 6) Workflow page +- Show `/api/workflow/requests` with filters + - `status` (`DRAFT`, `PENDING`, `APPROVED`, `REJECTED`) + - `resourceType` + - `makerUsername` + - `limit` +- Actions: + - Approve modal + - Reject modal + - show notes and optional checkerRole (if omitted, backend uses step role default `CHECKER`) + +### 7) Audit page +- Admin-only +- `GET /api/audit?limit=50` +- render `action`, `resourceType`, `resourceId`, before/after snapshots, outcome, correlation id +- Keep pagination/infinite-load support for audit + workflow lists. + +## DTO references for implementation + +### Login +```ts +{ username: string; password: string } +``` + +### Create user management request +- Local mode: +```ts +{ + username: string + password: string + enabled?: boolean + roleCodes: string[] +} +``` +- LDAP mode: +```ts +{ + username: string + ldapDn?: string + enabled?: boolean + roleCodes: string[] +} +``` + +### Update user roles +```ts +{ username: string; roleCodes: string[] } +``` + +### Create role request +```ts +{ code: string; name: string; permissionCodes: string[] } +``` + +### Update role permissions +```ts +{ code: string; permissionCodes: string[] } +``` + +### Workflow action +```ts +{ notes?: string; checkerRole?: string } +``` + +### Create approval request (generic) +```ts +{ resourceType: string; resourceId: string; payload?: string; requiredSteps: number } +``` + +### Response from `/api/users/me` +```ts +{ tenantId: string; username: string; roles: string[]; permissions: string[] } +``` + +## UI requirements + +- Use Tabler-inspired components for + - tables + - forms + - modals + - badges + - alerts +- Keep navigation corporate and simple +- Add loading states, inline error states, and toast notifications +- Keep table columns configurable (search, sort, pagination) + +## Error handling + +Backend may return these patterns: +- Login failures with localized message +- Lockout message key in i18n when brute force threshold exceeded +- Standard `ApiResponse` with `success` false + +Frontend should: +- show notification from `message` +- maintain tenant context in state across page refresh/login switch +- keep unauthorized navigation blocked by RBAC-derived route guards + +## Delivery expectations + +Please generate runnable code for: +- `app/` shell and route layout +- Axios client with interceptors +- Login/auth flow +- Tenant-aware request wrapper +- Users module screens + workflow request forms +- Roles module screens + workflow request forms +- Workflow list/detail with approve/reject action +- Audit list +- Reusable table/form/modal components + +Please include a short setup checklist: +- env vars (`NEXT_PUBLIC_API_BASE_URL` etc) +- install commands +- run instructions diff --git a/docs/sequence-diagrams.md b/docs/sequence-diagrams.md new file mode 100644 index 0000000..6165a88 --- /dev/null +++ b/docs/sequence-diagrams.md @@ -0,0 +1,417 @@ +# Controller Sequence Diagrams + +All diagrams are in Mermaid syntax (` ```mermaid `) and can be rendered by GitHub, IntelliJ, VS Code Mermaid extensions, and most markdown tools. + +## 1) AuthController (`/api/auth`) + +### 1.1 POST `/api/auth/login` +```mermaid +sequenceDiagram + autonumber + actor Client + participant AC as AuthController + participant AF as AuthService + participant TF as TenantFilter + participant TS as TenantService + participant LT as LoginThrottleService + participant AM as AuthenticationManager + participant JR as JwtService + participant UR as UserRepository + participant RTR as RefreshTokenRepository + + Client->>AC: POST /api/auth/login {tenant, username, password} + AC->>AC: MessageResolver message key + AC-->>TF: tenant header (X-Tenant-Id) + TF->>TF: TenantContext.setTenantId(tenant) + + AC->>AF: login(request) + AF->>TS: getActiveTenant(tenantId) + TS-->>AF: tenant entity + AF->>LT: ensureAllowed(tenantId, username) + alt account locked + LT-->>AF: AppException(auth.login.locked) + else allowed + AF->>AM: authenticate(UsernamePasswordAuthenticationToken) + alt invalid credential + AM-->>AF: AuthenticationException + AF->>LT: recordFailure(tenantId, username) + AF-->>AC: throw AppException(auth.invalid.credentials) + AC-->>Client: 400 Error via GlobalExceptionHandler + else success + AF->>LT: recordSuccess(tenantId, username) + AF->>UR: findByTenantIdAndUsername + UR-->>AF: User + AF->>AF: build UserPrincipal + AF->>JR: generateAccessToken(principal) + AF->>JR: generateRefreshToken(principal) + AF->>RTR: delete old refresh tokens + AF->>RTR: save RefreshToken + AF-->>AC: AuthTokenResponse(Bearer, access, refresh) + AC-->>Client: 200 {success:true, message, token} + end + end +``` + +### 1.2 POST `/api/auth/refresh` +```mermaid +sequenceDiagram + autonumber + actor Client + participant AF as AuthService + participant AC as AuthController + participant TF as TenantFilter + participant RTR as RefreshTokenRepository + participant JR as JwtService + participant UR as UserRepository + + Client->>AC: POST /api/auth/refresh {tenant, refreshToken} + AC-->>TF: resolve tenant header + AC->>AF: refresh(request) + AF->>AF: TenantContext.getRequiredTenantId + AF->>RTR: findByTokenAndTenantId(refreshToken, tenantId) + alt token not found + RTR-->>AF: empty + AF-->>AC: AppException("Refresh token not found") + AC-->>Client: 400 via GlobalExceptionHandler + else token found + AF->>AF: validate revoked/expired + AF->>JR: parseClaims(refreshToken) + AF->>UR: findByTenantIdAndUsername(claims.sub) + AF->>JR: generateAccessToken(principal) + AF-->>AC: AuthTokenResponse(new access token) + AC-->>Client: 200 {success:true, message, token} + end +``` + +### 1.3 POST `/api/auth/logout` +```mermaid +sequenceDiagram + autonumber + actor Client + participant AC as AuthController + participant SE as Spring Security + participant AF as AuthService + participant TBS as TokenBlacklistService + participant JR as JwtService + + Client->>AC: POST /api/auth/logout Authorization: Bearer + AC->>SE: isAuthenticated() method security + alt unauthenticated + SE-->>Client: 401/403 + else authenticated + AC->>AF: logout(bearer token) + alt token missing/blank + AF->>AF: return + else token present + AF->>JR: parseClaims(access token) + AF->>AF: compute ttl from exp + AF->>TBS: blacklist(token, ttl) + end + AF-->>AC: void + AC-->>Client: 200 {success:true, logout message} + end +``` + +## 2) WorkflowController (`/api/workflow`) + +### 2.1 POST `/api/workflow/request` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant WC as ApprovalWorkflowController + participant AFS as ApprovalWorkflowService + participant TS as TenantService + participant AR as ApprovalRequestRepository + participant ASR as ApprovalStepRepository + participant AH as ApprovalHistoryRepository + participant AT as AuditTrailService + + Client->>WC: POST /api/workflow/request (workflow payload) + WC->>SC: hasAuthority('WORKFLOW_CREATE') OR hasRole('MAKER') + alt auth failed + SC-->>Client: 403 + else authorized + WC->>AFS: createRequest(dto, servletRequest) + AFS->>TS: getActiveTenant(tenantId) + AFS->>AFS: resolve maker + checkerRole default + AFS->>AR: save ApprovalRequest(PENDING, status=0) + loop for each requiredSteps + AFS->>ASR: save ApprovalStep(stepOrder, CHECKER role) + end + AFS->>AH: addHistory(action=CREATE) + AFS->>AT: record(ACTION_CREATE, before=null, after=snapshot) + AFS-->>WC: ApprovalResponse + WC-->>Client: 200 workflow.request.created + end +``` + +### 2.2 POST `/api/workflow/{id}/approve` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant WC as ApprovalWorkflowController + participant AFS as ApprovalWorkflowService + participant AR as ApprovalRequestRepository + participant ASR as ApprovalStepRepository + participant AH as ApprovalHistoryRepository + participant EVT as ApprovalEventProducer + participant AT as AuditTrailService + + Client->>WC: POST /api/workflow/{id}/approve (action notes) + WC->>SC: hasAuthority('WORKFLOW_APPROVE') OR hasRole('CHECKER') + alt auth failed + SC-->>Client: 403 + else authorized + WC->>AFS: approve(id, dto, auth, servletRequest) + AFS->>AR: findByIdAndTenantId(id, tenantId) + alt request not found or not pending + AFS-->>WC: AppException + WC-->>Client: 400 via handler + else valid + AFS->>ASR: find current step + AFS->>SC: validateCheckerRole(auth, expectedRole) + AFS->>ASR: save step status=APPROVED + AFS->>AR: update currentStep + alt all steps completed + AFS->>AR: set request status=APPROVED + AFS->>EVT: publishCompleted(ApprovalCompletedEvent) + end + AFS->>AH: addHistory(action=APPROVE) + AFS->>AT: record(ACTION_APPROVE, before/after states) + AFS-->>WC: ApprovalResponse + WC-->>Client: 200 workflow.request.approved + end + end +``` + +### 2.3 POST `/api/workflow/{id}/reject` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant WC as ApprovalWorkflowController + participant AFS as ApprovalWorkflowService + participant AR as ApprovalRequestRepository + participant ASR as ApprovalStepRepository + participant AH as ApprovalHistoryRepository + participant AT as AuditTrailService + + Client->>WC: POST /api/workflow/{id}/reject (action notes) + WC->>SC: hasAuthority('WORKFLOW_APPROVE') OR hasRole('CHECKER') + alt auth failed + SC-->>Client: 403 + else authorized + WC->>AFS: reject(id, dto, auth, servletRequest) + AFS->>AR: findByIdAndTenantId(id, tenantId) + alt request not found or not pending + AFS-->>WC: AppException + WC-->>Client: 400 via handler + else valid + AFS->>ASR: find current step + AFS->>SC: validateCheckerRole(auth, expectedRole) + AFS->>ASR: save step status=REJECTED + AFS->>AR: set request status=REJECTED + AFS->>AH: addHistory(action=REJECT) + AFS->>AT: record(ACTION_REJECT, before/after states) + AFS-->>WC: ApprovalResponse + WC-->>Client: 200 workflow.request.rejected + end + end +``` + +## 3) UserController (`/api/users`) + +### 3.1 GET `/api/users/me` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Security Filter + Method Security + participant UC as UserController + participant US as UserService + participant UR as UserRepository + + Client->>UC: GET /api/users/me + UC->>SC: hasAuthority('USER_READ') OR hasRole('ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + UC->>US: me(authentication.username) + US->>UR: findByTenantIdAndUsername + UR-->>US: User + US-->>UC: CurrentUserResponse(roles, permissions) + UC-->>Client: 200 {tenantId, user details} + end +``` + +### 3.2 POST `/api/users/management/requests/create` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant UC as UserController + participant URS as UserRoleManagementService + participant AS as ApprovalWorkflowService + participant AR as ApprovalRequestRepository + + Client->>UC: POST /api/users/management/requests/create + UC->>SC: hasAuthority('USER_MANAGE') OR hasRole('USER_ROLE_ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + UC->>URS: submitCreateUserRequest(request, servletRequest) + URS->>AS: createRequest(resource=USER_MANAGEMENT, requiredSteps=1, checkerRole=USER_ROLE_ADMIN) + AS->>AR: persist pending approval request + steps + AS-->>URS: ApprovalResponse + URS-->>UC: ApprovalResponse + UC-->>Client: 200 user.management.request.created + end +``` + +### 3.3 POST `/api/users/management/requests/update-roles` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant UC as UserController + participant URS as UserRoleManagementService + participant AR as ApprovalRequestRepository + + Client->>UC: POST /api/users/management/requests/update-roles + UC->>SC: hasAuthority('USER_MANAGE') OR hasRole('USER_ROLE_ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + UC->>URS: submitUpdateUserRolesRequest(request) + URS->>AR: validate user + roles, build payload + URS->>AR: create approval request with step checker role + URS-->>UC: ApprovalResponse + UC-->>Client: 200 user.management.request.created + end +``` + +## 4) RoleController (`/api/roles`) + +### 4.1 POST `/api/roles/management/requests/create` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant RC as RoleController + participant URS as UserRoleManagementService + participant AS as ApprovalWorkflowService + + Client->>RC: POST /api/roles/management/requests/create + RC->>SC: hasAuthority('ROLE_MANAGE') OR hasRole('USER_ROLE_ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + RC->>URS: submitCreateRoleRequest(request, servletRequest) + URS->>AS: createRequest(resource=ROLE_MANAGEMENT, requiredSteps=1) + AS-->>URS: ApprovalResponse + URS-->>RC: ApprovalResponse + RC-->>Client: 200 role.management.request.created + end +``` + +### 4.2 POST `/api/roles/management/requests/update-permissions` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant RC as RoleController + participant URS as UserRoleManagementService + participant AS as ApprovalWorkflowService + + Client->>RC: POST /api/roles/management/requests/update-permissions + RC->>SC: hasAuthority('ROLE_MANAGE') OR hasRole('USER_ROLE_ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + RC->>URS: submitUpdateRolePermissionsRequest(request, servletRequest) + URS->>AS: createRequest(resource=ROLE_MANAGEMENT, requiredSteps=1) + AS-->>URS: ApprovalResponse + URS-->>RC: ApprovalResponse + RC-->>Client: 200 role.management.request.created + end +``` + +## 5) AuditController (`/api/audit`) + +### 5.1 GET `/api/audit?limit={n}` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant AC as AuditController + participant TS as TenantContext + participant ATS as AuditTrailService + participant ARepo as AuditTrailRepository + + Client->>AC: GET /api/audit?limit=50 + AC->>SC: hasRole('ADMIN') + alt unauthorized + SC-->>Client: 403 + else authorized + AC->>TS: getRequiredTenantId() + AC->>ATS: listRecent(tenantId, limit) + ATS->>ARepo: find top by tenant/order by createdAt desc + ARepo-->>ATS: list audit entities + ATS-->>AC: mapped list entities + AC-->>AC: map to AuditTrailResponse DTOs + AC-->>Client: 200 audit.list.success + end +``` + +## 6) TenantController (`/api/tenant`) + +### 6.1 GET `/api/tenant/context` +```mermaid +sequenceDiagram + autonumber + actor Client + participant SC as Spring Security + participant TC as TenantController + participant TCX as TenantContext + + Client->>TC: GET /api/tenant/context + TC->>SC: isAuthenticated() + alt unauthenticated + SC-->>Client: 401/403 + else authenticated + TC->>TCX: getRequiredTenantId() + TC-->>Client: 200 {tenantId} + end +``` + +## Common Cross-Cutting Exception Flow + +### Validation and Error handling for all controllers +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller + participant Handler as GlobalExceptionHandler + + Client->>Controller: Invalid body / business rule violation + Controller-->>Handler: throws MethodArgumentNotValidException or AppException + alt AppException + Handler-->>Client: 400 ApiResponse.fail(message) + else AccessDenied + Handler-->>Client: 403 ApiResponse.fail("error.forbidden") + else General exception + Handler-->>Client: 500 ApiResponse.fail("error.internal") + end +``` + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..796ef05 --- /dev/null +++ b/pom.xml @@ -0,0 +1,116 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + id.iptek + utms-ng-be + 1.0.0 + utms-ng-be + Multi-tenant Spring Boot backend with workflow and modular system + + + 17 + 0.12.6 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-activemq + + + org.springframework.boot + spring-boot-starter-data-ldap + + + org.springframework.security + spring-security-ldap + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + org.postgresql + postgresql + runtime + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/id/iptek/utms/UtmsNgBeApplication.java b/src/main/java/id/iptek/utms/UtmsNgBeApplication.java new file mode 100644 index 0000000..f0b2edd --- /dev/null +++ b/src/main/java/id/iptek/utms/UtmsNgBeApplication.java @@ -0,0 +1,21 @@ +package id.iptek.utms; + +import java.time.ZoneId; +import java.util.Locale; +import java.util.TimeZone; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +@EnableCaching +@SpringBootApplication +public class UtmsNgBeApplication { + + public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Jakarta"))); + Locale.setDefault(Locale.forLanguageTag("en-US")); + SpringApplication.run(UtmsNgBeApplication.class, args); + } +} + diff --git a/src/main/java/id/iptek/utms/api/ApiResponse.java b/src/main/java/id/iptek/utms/api/ApiResponse.java new file mode 100644 index 0000000..286d34a --- /dev/null +++ b/src/main/java/id/iptek/utms/api/ApiResponse.java @@ -0,0 +1,19 @@ +package id.iptek.utms.api; + +import java.time.Instant; + +public record ApiResponse( + boolean success, + String message, + T data, + Instant timestamp +) { + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data, Instant.now()); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(false, message, null, Instant.now()); + } +} + diff --git a/src/main/java/id/iptek/utms/api/AuditController.java b/src/main/java/id/iptek/utms/api/AuditController.java new file mode 100644 index 0000000..f625ce2 --- /dev/null +++ b/src/main/java/id/iptek/utms/api/AuditController.java @@ -0,0 +1,62 @@ +package id.iptek.utms.api; + +import id.iptek.utms.core.audit.dto.AuditTrailResponse; +import id.iptek.utms.core.audit.domain.AuditTrail; +import id.iptek.utms.core.audit.service.AuditTrailService; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.tenant.TenantContext; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/audit") +@SecurityRequirement(name = "bearerAuth") +public class AuditController { + + private final AuditTrailService auditTrailService; + private final MessageResolver messageResolver; + + public AuditController(AuditTrailService auditTrailService, MessageResolver messageResolver) { + this.auditTrailService = auditTrailService; + this.messageResolver = messageResolver; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse> listRecent(@RequestParam(defaultValue = "50") int limit) { + String tenantId = TenantContext.getRequiredTenantId(); + List trails = auditTrailService.listRecent(tenantId, limit); + + return ApiResponse.ok( + messageResolver.get("audit.list.success"), + trails.stream().map(this::toResponse).toList() + ); + } + + private AuditTrailResponse toResponse(AuditTrail trail) { + return new AuditTrailResponse( + trail.getId(), + trail.getTenantId(), + trail.getActor(), + trail.getCorrelationId(), + trail.getAction(), + trail.getDomain(), + trail.getResourceType(), + trail.getResourceId(), + trail.getOutcome(), + trail.getHttpMethod(), + trail.getRequestPath(), + trail.getErrorMessage(), + trail.getBeforeState(), + trail.getAfterState(), + trail.getDetails(), + trail.getCreatedAt() + ); + } +} diff --git a/src/main/java/id/iptek/utms/api/RoleController.java b/src/main/java/id/iptek/utms/api/RoleController.java new file mode 100644 index 0000000..a1bcc52 --- /dev/null +++ b/src/main/java/id/iptek/utms/api/RoleController.java @@ -0,0 +1,50 @@ +package id.iptek.utms.api; + +import id.iptek.utms.auth.dto.CreateRoleManagementRequest; +import id.iptek.utms.auth.dto.UpdateRolePermissionsRequest; +import id.iptek.utms.auth.service.UserRoleManagementService; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.workflow.dto.ApprovalResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/roles") +@SecurityRequirement(name = "bearerAuth") +public class RoleController { + + private final UserRoleManagementService userRoleManagementService; + private final MessageResolver messageResolver; + + public RoleController(UserRoleManagementService userRoleManagementService, + MessageResolver messageResolver) { + this.userRoleManagementService = userRoleManagementService; + this.messageResolver = messageResolver; + } + + @PostMapping("/management/requests/create") + @PreAuthorize("hasAuthority('ROLE_MANAGE') or hasRole('USER_ROLE_ADMIN')") + public ApiResponse create(@Valid @RequestBody CreateRoleManagementRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok( + messageResolver.get("role.management.request.created"), + userRoleManagementService.submitCreateRoleRequest(request, servletRequest) + ); + } + + @PostMapping("/management/requests/update-permissions") + @PreAuthorize("hasAuthority('ROLE_MANAGE') or hasRole('USER_ROLE_ADMIN')") + public ApiResponse updatePermissions(@Valid @RequestBody UpdateRolePermissionsRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok( + messageResolver.get("role.management.request.created"), + userRoleManagementService.submitUpdateRolePermissionsRequest(request, servletRequest) + ); + } +} diff --git a/src/main/java/id/iptek/utms/api/TenantController.java b/src/main/java/id/iptek/utms/api/TenantController.java new file mode 100644 index 0000000..aae3af8 --- /dev/null +++ b/src/main/java/id/iptek/utms/api/TenantController.java @@ -0,0 +1,31 @@ +package id.iptek.utms.api; + +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.tenant.TenantContext; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/tenant") +@SecurityRequirement(name = "bearerAuth") +public class TenantController { + + private final MessageResolver messageResolver; + + public TenantController(MessageResolver messageResolver) { + this.messageResolver = messageResolver; + } + + @GetMapping("/context") + @PreAuthorize("isAuthenticated()") + public ApiResponse> tenantContext() { + return ApiResponse.ok("Tenant context resolved", + Map.of("tenantId", TenantContext.getRequiredTenantId())); + } +} + diff --git a/src/main/java/id/iptek/utms/api/UserController.java b/src/main/java/id/iptek/utms/api/UserController.java new file mode 100644 index 0000000..80c37a5 --- /dev/null +++ b/src/main/java/id/iptek/utms/api/UserController.java @@ -0,0 +1,136 @@ +package id.iptek.utms.api; + +import id.iptek.utms.auth.dto.CreateUserManagementRequest; +import id.iptek.utms.auth.dto.CurrentUserResponse; +import id.iptek.utms.auth.dto.UpdateUserRolesRequest; +import id.iptek.utms.auth.service.UserRoleManagementService; +import id.iptek.utms.auth.service.UserService; +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.preference.dto.TablePreferenceProfile; +import id.iptek.utms.preference.dto.TablePreferenceRequest; +import id.iptek.utms.preference.dto.TablePreferenceSavedProfile; +import id.iptek.utms.preference.dto.UserUiPreferencesResponse; +import id.iptek.utms.preference.service.UserPreferenceService; +import id.iptek.utms.tenant.TenantContext; +import id.iptek.utms.tenant.TenantFilter; +import id.iptek.utms.workflow.dto.ApprovalResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/api/users") +@SecurityRequirement(name = "bearerAuth") +public class UserController { + + private final UserService userService; + private final UserRoleManagementService userRoleManagementService; + private final UserPreferenceService userPreferenceService; + private final MessageResolver messageResolver; + + public UserController(UserService userService, + UserRoleManagementService userRoleManagementService, + UserPreferenceService userPreferenceService, + MessageResolver messageResolver) { + this.userService = userService; + this.userRoleManagementService = userRoleManagementService; + this.userPreferenceService = userPreferenceService; + this.messageResolver = messageResolver; + } + + @GetMapping("/me") + @PreAuthorize("hasAuthority('USER_READ') or hasRole('ADMIN')") + public ApiResponse me(Authentication authentication) { + CurrentUserResponse response = userService.me(authentication.getName()); + return ApiResponse.ok(messageResolver.get("user.me.success"), response); + } + + @GetMapping("/preferences") + @PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()") + public ApiResponse getPreferences( + @RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId, + Authentication authentication) { + requireTenantHeader(tenantId); + return ApiResponse.ok(messageResolver.get("user.preferences.get.success"), + userPreferenceService.getAll(authentication)); + } + + @PutMapping("/preferences/table") + @PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()") + public ApiResponse upsertTablePreference( + @RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId, + Authentication authentication, + @Valid @RequestBody TablePreferenceRequest request) { + requireTenantHeader(tenantId); + return ApiResponse.ok(messageResolver.get("user.preferences.upsert.success"), + userPreferenceService.upsert(authentication, request)); + } + + @DeleteMapping("/preferences/table/{preferenceKey}") + @PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()") + public ApiResponse resetTablePreference( + @RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId, + Authentication authentication, + @PathVariable String preferenceKey) { + requireTenantHeader(tenantId); + return ApiResponse.ok(messageResolver.get("user.preferences.reset.table.success"), + userPreferenceService.resetTablePreference(authentication, preferenceKey)); + } + + @DeleteMapping("/preferences") + @PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()") + public ApiResponse resetAllPreferences( + @RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId, + Authentication authentication) { + requireTenantHeader(tenantId); + userPreferenceService.resetAll(authentication); + return ApiResponse.ok(messageResolver.get("user.preferences.reset.all.success"), null); + } + + @PostMapping("/management/requests/create") + @PreAuthorize("hasAuthority('USER_MANAGE') or hasRole('USER_ROLE_ADMIN')") + public ApiResponse create(@Valid @RequestBody CreateUserManagementRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok( + messageResolver.get("user.management.request.created"), + userRoleManagementService.submitCreateUserRequest(request, servletRequest) + ); + } + + @PostMapping("/management/requests/update-roles") + @PreAuthorize("hasAuthority('USER_MANAGE') or hasRole('USER_ROLE_ADMIN')") + public ApiResponse updateRoles(@Valid @RequestBody UpdateUserRolesRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok( + messageResolver.get("user.management.request.created"), + userRoleManagementService.submitUpdateUserRolesRequest(request, servletRequest) + ); + } + + private void requireTenantHeader(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + throw new AppException(messageResolver.get("tenant.header.required")); + } + String contextTenant = TenantContext.getTenantId(); + if (contextTenant == null || contextTenant.isBlank()) { + throw new AppException(messageResolver.get("tenant.header.required")); + } + if (!Objects.equals(tenantId, contextTenant)) { + throw new AppException(messageResolver.get("tenant.header.mismatch")); + } + } +} diff --git a/src/main/java/id/iptek/utms/auth/config/JwtProperties.java b/src/main/java/id/iptek/utms/auth/config/JwtProperties.java new file mode 100644 index 0000000..01161e3 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/config/JwtProperties.java @@ -0,0 +1,12 @@ +package id.iptek.utms.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.jwt") +public record JwtProperties( + String secret, + long accessTokenMinutes, + long refreshTokenDays +) { +} + diff --git a/src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java b/src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java new file mode 100644 index 0000000..1e54f89 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java @@ -0,0 +1,45 @@ +package id.iptek.utms.auth.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.ldap.DefaultSpringSecurityContextSource; +import org.springframework.security.ldap.authentication.BindAuthenticator; +import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; +import org.springframework.security.ldap.search.FilterBasedLdapUserSearch; +import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; + +@Configuration +@EnableConfigurationProperties(LdapProperties.class) +public class LdapAuthConfig { + + @Bean + @ConditionalOnProperty(name = "app.ldap.enabled", havingValue = "true") + public DefaultSpringSecurityContextSource ldapContextSource(LdapProperties ldapProperties) { + DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource(ldapProperties.url()); + contextSource.setBase(ldapProperties.base()); + contextSource.setUserDn(ldapProperties.managerDn()); + contextSource.setPassword(ldapProperties.managerPassword()); + contextSource.afterPropertiesSet(); + return contextSource; + } + + @Bean(name = "ldapAuthenticationProvider") + @ConditionalOnProperty(name = "app.ldap.enabled", havingValue = "true") + public AuthenticationProvider ldapAuthenticationProvider(DefaultSpringSecurityContextSource ldapContextSource, + LdapProperties ldapProperties) { + BindAuthenticator bindAuthenticator = new BindAuthenticator(ldapContextSource); + FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch( + ldapProperties.userSearchBase(), + ldapProperties.userSearchFilter(), + ldapContextSource + ); + bindAuthenticator.setUserSearch(userSearch); + + LdapAuthenticationProvider provider = new LdapAuthenticationProvider(bindAuthenticator); + provider.setUserDetailsContextMapper(new LdapUserDetailsMapper()); + return provider; + } +} diff --git a/src/main/java/id/iptek/utms/auth/config/LdapProperties.java b/src/main/java/id/iptek/utms/auth/config/LdapProperties.java new file mode 100644 index 0000000..ca06fb4 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/config/LdapProperties.java @@ -0,0 +1,17 @@ +package id.iptek.utms.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.ldap") +public record LdapProperties( + boolean enabled, + String url, + String base, + String managerDn, + String managerPassword, + String userSearchBase, + String userSearchFilter, + String groupSearchBase, + String groupSearchFilter +) { +} diff --git a/src/main/java/id/iptek/utms/auth/config/SecurityConfig.java b/src/main/java/id/iptek/utms/auth/config/SecurityConfig.java new file mode 100644 index 0000000..261ef5f --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package id.iptek.utms.auth.config; + +import id.iptek.utms.auth.config.LdapProperties; +import id.iptek.utms.auth.security.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableMethodSecurity +@EnableConfigurationProperties(JwtProperties.class) +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final UserDetailsService userDetailsService; + private final AuthenticationProvider ldapAuthenticationProvider; + private final LdapProperties ldapProperties; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, + UserDetailsService userDetailsService, + @Autowired(required = false) @Qualifier("ldapAuthenticationProvider") AuthenticationProvider ldapAuthenticationProvider, + LdapProperties ldapProperties) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.userDetailsService = userDetailsService; + this.ldapAuthenticationProvider = ldapAuthenticationProvider; + this.ldapProperties = ldapProperties; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authenticationProvider(authenticationProvider()); + + if (ldapProperties.enabled() && ldapAuthenticationProvider != null) { + http.authenticationProvider(ldapAuthenticationProvider); + } + + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/controller/AuthController.java b/src/main/java/id/iptek/utms/auth/controller/AuthController.java new file mode 100644 index 0000000..0d5de7f --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/controller/AuthController.java @@ -0,0 +1,52 @@ +package id.iptek.utms.auth.controller; + +import id.iptek.utms.api.ApiResponse; +import id.iptek.utms.auth.dto.AuthTokenResponse; +import id.iptek.utms.auth.dto.LoginRequest; +import id.iptek.utms.auth.dto.RefreshRequest; +import id.iptek.utms.auth.service.AuthService; +import id.iptek.utms.core.i18n.MessageResolver; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@Tag(name = "Authentication") +public class AuthController { + + private final AuthService authService; + private final MessageResolver messageResolver; + + public AuthController(AuthService authService, MessageResolver messageResolver) { + this.authService = authService; + this.messageResolver = messageResolver; + } + + @PostMapping("/login") + @Operation(summary = "Login", description = "Returns access and refresh token") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.ok(messageResolver.get("auth.login.success"), authService.login(request)); + } + + @PostMapping("/refresh") + @Operation(summary = "Refresh token") + public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) { + return ApiResponse.ok(messageResolver.get("auth.refresh.success"), authService.refresh(request)); + } + + @PostMapping("/logout") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "Logout and blacklist token") + public ApiResponse logout(HttpServletRequest request) { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String token = authHeader != null && authHeader.startsWith("Bearer ") ? authHeader.substring(7) : null; + authService.logout(token); + return ApiResponse.ok(messageResolver.get("auth.logout.success"), null); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/domain/AuthenticationSource.java b/src/main/java/id/iptek/utms/auth/domain/AuthenticationSource.java new file mode 100644 index 0000000..b026844 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/domain/AuthenticationSource.java @@ -0,0 +1,6 @@ +package id.iptek.utms.auth.domain; + +public enum AuthenticationSource { + LOCAL, + LDAP +} diff --git a/src/main/java/id/iptek/utms/auth/domain/Permission.java b/src/main/java/id/iptek/utms/auth/domain/Permission.java new file mode 100644 index 0000000..b068178 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/domain/Permission.java @@ -0,0 +1,37 @@ +package id.iptek.utms.auth.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sec_permissions", uniqueConstraints = { + @UniqueConstraint(name = "sec_uk_permissions_tenant_code", columnNames = {"tenant_id", "code"}) +}) +public class Permission extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private String name; + + @ManyToMany(mappedBy = "permissions") + private Set roles = new HashSet<>(); +} + diff --git a/src/main/java/id/iptek/utms/auth/domain/RefreshToken.java b/src/main/java/id/iptek/utms/auth/domain/RefreshToken.java new file mode 100644 index 0000000..7647960 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/domain/RefreshToken.java @@ -0,0 +1,40 @@ +package id.iptek.utms.auth.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sec_refresh_tokens", indexes = { + @Index(name = "sec_idx_refresh_token", columnList = "token", unique = true) +}) +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true, length = 512) + private String token; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(nullable = false) + private boolean revoked; +} + diff --git a/src/main/java/id/iptek/utms/auth/domain/Role.java b/src/main/java/id/iptek/utms/auth/domain/Role.java new file mode 100644 index 0000000..29f6c36 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/domain/Role.java @@ -0,0 +1,45 @@ +package id.iptek.utms.auth.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sec_roles", uniqueConstraints = { + @UniqueConstraint(name = "sec_uk_roles_tenant_code", columnNames = {"tenant_id", "code"}) +}) +public class Role extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private String name; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "sec_role_permissions", + joinColumns = @JoinColumn(name = "role_id"), + inverseJoinColumns = @JoinColumn(name = "permission_id") + ) + private Set permissions = new HashSet<>(); + + @ManyToMany(mappedBy = "roles") + private Set users = new HashSet<>(); +} + diff --git a/src/main/java/id/iptek/utms/auth/domain/User.java b/src/main/java/id/iptek/utms/auth/domain/User.java new file mode 100644 index 0000000..0e5fdba --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/domain/User.java @@ -0,0 +1,55 @@ +package id.iptek.utms.auth.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; +import org.hibernate.annotations.FilterDef; +import org.hibernate.annotations.ParamDef; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class)) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sec_users", uniqueConstraints = { + @UniqueConstraint(name = "sec_uk_users_tenant_username", columnNames = {"tenant_id", "username"}) +}) +public class User extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(nullable = false) + private String username; + + @Column + private String password; + + @Enumerated(EnumType.STRING) + @Column(name = "auth_source", nullable = false) + private AuthenticationSource authSource = AuthenticationSource.LOCAL; + + @Column(name = "ldap_dn") + private String ldapDn; + + @Column(nullable = false) + private boolean enabled = true; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "sec_user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); +} + diff --git a/src/main/java/id/iptek/utms/auth/dto/AuthTokenResponse.java b/src/main/java/id/iptek/utms/auth/dto/AuthTokenResponse.java new file mode 100644 index 0000000..d708fb5 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/AuthTokenResponse.java @@ -0,0 +1,10 @@ +package id.iptek.utms.auth.dto; + +public record AuthTokenResponse( + String tokenType, + String accessToken, + String refreshToken, + long expiresInSeconds +) { +} + diff --git a/src/main/java/id/iptek/utms/auth/dto/CreateRoleManagementRequest.java b/src/main/java/id/iptek/utms/auth/dto/CreateRoleManagementRequest.java new file mode 100644 index 0000000..dff9f9f --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/CreateRoleManagementRequest.java @@ -0,0 +1,13 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.Set; + +public record CreateRoleManagementRequest( + @NotBlank String code, + @NotBlank String name, + @NotEmpty Set<@NotBlank String> permissionCodes +) { +} diff --git a/src/main/java/id/iptek/utms/auth/dto/CreateUserManagementRequest.java b/src/main/java/id/iptek/utms/auth/dto/CreateUserManagementRequest.java new file mode 100644 index 0000000..98f1d17 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/CreateUserManagementRequest.java @@ -0,0 +1,26 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.Set; + +public record CreateUserManagementRequest( + @NotBlank String username, + String password, + String ldapDn, + Boolean enabled, + @NotEmpty Set<@NotBlank String> roleCodes +) { + public boolean isEnabled() { + return enabled == null || enabled; + } + + public String normalizedLdapDn() { + return ldapDn == null ? null : ldapDn.trim(); + } + + public boolean hasPassword() { + return password != null && !password.isBlank(); + } +} diff --git a/src/main/java/id/iptek/utms/auth/dto/CurrentUserResponse.java b/src/main/java/id/iptek/utms/auth/dto/CurrentUserResponse.java new file mode 100644 index 0000000..d547230 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/CurrentUserResponse.java @@ -0,0 +1,12 @@ +package id.iptek.utms.auth.dto; + +import java.util.Set; + +public record CurrentUserResponse( + String tenantId, + String username, + Set roles, + Set permissions +) { +} + diff --git a/src/main/java/id/iptek/utms/auth/dto/LoginRequest.java b/src/main/java/id/iptek/utms/auth/dto/LoginRequest.java new file mode 100644 index 0000000..4f32732 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/LoginRequest.java @@ -0,0 +1,10 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String username, + @NotBlank String password +) { +} + diff --git a/src/main/java/id/iptek/utms/auth/dto/RefreshRequest.java b/src/main/java/id/iptek/utms/auth/dto/RefreshRequest.java new file mode 100644 index 0000000..e8b0844 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/RefreshRequest.java @@ -0,0 +1,9 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshRequest( + @NotBlank String refreshToken +) { +} + diff --git a/src/main/java/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.java b/src/main/java/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.java new file mode 100644 index 0000000..efa6248 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.java @@ -0,0 +1,12 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.Set; + +public record UpdateRolePermissionsRequest( + @NotBlank String code, + @NotEmpty Set<@NotBlank String> permissionCodes +) { +} diff --git a/src/main/java/id/iptek/utms/auth/dto/UpdateUserRolesRequest.java b/src/main/java/id/iptek/utms/auth/dto/UpdateUserRolesRequest.java new file mode 100644 index 0000000..d0deead --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/dto/UpdateUserRolesRequest.java @@ -0,0 +1,12 @@ +package id.iptek.utms.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.Set; + +public record UpdateUserRolesRequest( + @NotBlank String username, + @NotEmpty Set<@NotBlank String> roleCodes +) { +} diff --git a/src/main/java/id/iptek/utms/auth/repository/PermissionRepository.java b/src/main/java/id/iptek/utms/auth/repository/PermissionRepository.java new file mode 100644 index 0000000..f2f8815 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/repository/PermissionRepository.java @@ -0,0 +1,16 @@ +package id.iptek.utms.auth.repository; + +import id.iptek.utms.auth.domain.Permission; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PermissionRepository extends JpaRepository { + Optional findByTenantIdAndCode(String tenantId, String code); + + List findByTenantIdAndCodeIn(String tenantId, Collection codes); +} + diff --git a/src/main/java/id/iptek/utms/auth/repository/RefreshTokenRepository.java b/src/main/java/id/iptek/utms/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..cb14802 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package id.iptek.utms.auth.repository; + +import id.iptek.utms.auth.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByTokenAndTenantId(String token, String tenantId); + void deleteByUser_IdAndTenantId(UUID userId, String tenantId); +} + diff --git a/src/main/java/id/iptek/utms/auth/repository/RoleRepository.java b/src/main/java/id/iptek/utms/auth/repository/RoleRepository.java new file mode 100644 index 0000000..b826087 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/repository/RoleRepository.java @@ -0,0 +1,16 @@ +package id.iptek.utms.auth.repository; + +import id.iptek.utms.auth.domain.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface RoleRepository extends JpaRepository { + Optional findByTenantIdAndCode(String tenantId, String code); + + List findByTenantIdAndCodeIn(String tenantId, Collection codes); +} + diff --git a/src/main/java/id/iptek/utms/auth/repository/UserRepository.java b/src/main/java/id/iptek/utms/auth/repository/UserRepository.java new file mode 100644 index 0000000..02aa59a --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/repository/UserRepository.java @@ -0,0 +1,14 @@ +package id.iptek.utms.auth.repository; + +import id.iptek.utms.auth.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + Optional findByTenantIdAndUsername(String tenantId, String username); + + boolean existsByTenantIdAndUsername(String tenantId, String username); +} + diff --git a/src/main/java/id/iptek/utms/auth/security/JwtAuthenticationFilter.java b/src/main/java/id/iptek/utms/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1fd1a17 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,87 @@ +package id.iptek.utms.auth.security; + +import id.iptek.utms.auth.service.TokenBlacklistService; +import id.iptek.utms.auth.service.SingleLoginSessionService; +import id.iptek.utms.tenant.TenantContext; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + private final TokenBlacklistService tokenBlacklistService; + private final SingleLoginSessionService singleLoginSessionService; + + public JwtAuthenticationFilter(JwtService jwtService, + UserDetailsService userDetailsService, + TokenBlacklistService tokenBlacklistService, + SingleLoginSessionService singleLoginSessionService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + this.tokenBlacklistService = tokenBlacklistService; + this.singleLoginSessionService = singleLoginSessionService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + if (tokenBlacklistService.isBlacklisted(token)) { + filterChain.doFilter(request, response); + return; + } + + try { + Claims claims = jwtService.parseClaims(token); + String username = claims.getSubject(); + String tenant = claims.get("tenant", String.class); + String sessionId = claims.get("sid", String.class); + + if (TenantContext.getTenantId() == null && tenant != null) { + TenantContext.setTenantId(tenant); + } + + if (singleLoginSessionService.isEnabled() && !singleLoginSessionService.isSessionActive(tenant, username, sessionId)) { + filterChain.doFilter(request, response); + return; + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (jwtService.isTokenValid(token)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (Exception ignored) { + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/security/JwtService.java b/src/main/java/id/iptek/utms/auth/security/JwtService.java new file mode 100644 index 0000000..19f3514 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/security/JwtService.java @@ -0,0 +1,91 @@ +package id.iptek.utms.auth.security; + +import id.iptek.utms.auth.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.UUID; + +@Component +public class JwtService { + + private final JwtProperties jwtProperties; + + public JwtService(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + } + + public String generateAccessToken(UserPrincipal principal) { + return generateAccessToken(principal, null); + } + + public String generateAccessToken(UserPrincipal principal, String sessionId) { + Instant now = Instant.now(); + return buildToken(principal, now.plus(jwtProperties.accessTokenMinutes(), ChronoUnit.MINUTES), sessionId); + } + + public String generateRefreshToken(UserPrincipal principal) { + return generateRefreshToken(principal, null); + } + + public String generateRefreshToken(UserPrincipal principal, String sessionId) { + Instant now = Instant.now(); + return buildToken(principal, now.plus(jwtProperties.refreshTokenDays(), ChronoUnit.DAYS), sessionId); + } + + private String buildToken(UserPrincipal principal, Instant expiresAt, String sessionId) { + return Jwts.builder() + .subject(principal.getUsername()) + .claim("uid", principal.getId().toString()) + .claim("tenant", principal.getTenantId()) + .claim("sid", sessionId) + .issuedAt(Date.from(Instant.now())) + .expiration(Date.from(expiresAt)) + .signWith(secretKey()) + .compact(); + } + + public Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String extractUsername(String token) { + return parseClaims(token).getSubject(); + } + + public String extractTenant(String token) { + return parseClaims(token).get("tenant", String.class); + } + + public UUID extractUserId(String token) { + return UUID.fromString(parseClaims(token).get("uid", String.class)); + } + + public String extractSessionId(String token) { + return parseClaims(token).get("sid", String.class); + } + + public boolean isTokenValid(String token) { + return parseClaims(token).getExpiration().after(new Date()); + } + + public long getAccessExpiresInSeconds() { + return jwtProperties.accessTokenMinutes() * 60; + } + + private SecretKey secretKey() { + return Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/security/TenantAwareUserDetailsService.java b/src/main/java/id/iptek/utms/auth/security/TenantAwareUserDetailsService.java new file mode 100644 index 0000000..2ff83f0 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/security/TenantAwareUserDetailsService.java @@ -0,0 +1,27 @@ +package id.iptek.utms.auth.security; + +import id.iptek.utms.auth.repository.UserRepository; +import id.iptek.utms.tenant.TenantContext; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class TenantAwareUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public TenantAwareUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + String tenantId = TenantContext.getRequiredTenantId(); + return userRepository.findByTenantIdAndUsername(tenantId, username) + .map(UserPrincipal::new) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/security/UserPrincipal.java b/src/main/java/id/iptek/utms/auth/security/UserPrincipal.java new file mode 100644 index 0000000..011602e --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/security/UserPrincipal.java @@ -0,0 +1,87 @@ +package id.iptek.utms.auth.security; + +import id.iptek.utms.auth.domain.Permission; +import id.iptek.utms.auth.domain.Role; +import id.iptek.utms.auth.domain.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class UserPrincipal implements UserDetails { + + private final UUID id; + private final String tenantId; + private final String username; + private final String password; + private final boolean enabled; + private final Set authorities; + + public UserPrincipal(User user) { + this.id = user.getId(); + this.tenantId = user.getTenantId(); + this.username = user.getUsername(); + this.password = user.getPassword(); + this.enabled = user.isEnabled(); + this.authorities = mapAuthorities(user.getRoles()); + } + + private Set mapAuthorities(Set roles) { + Set mapped = new HashSet<>(); + for (Role role : roles) { + mapped.add(new SimpleGrantedAuthority("ROLE_" + role.getCode())); + for (Permission permission : role.getPermissions()) { + mapped.add(new SimpleGrantedAuthority(permission.getCode())); + } + } + return mapped; + } + + public UUID getId() { + return id; + } + + public String getTenantId() { + return tenantId; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return enabled; + } +} + diff --git a/src/main/java/id/iptek/utms/auth/service/AuthService.java b/src/main/java/id/iptek/utms/auth/service/AuthService.java new file mode 100644 index 0000000..6376f6a --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/AuthService.java @@ -0,0 +1,153 @@ +package id.iptek.utms.auth.service; + +import id.iptek.utms.auth.domain.RefreshToken; +import id.iptek.utms.auth.domain.User; +import id.iptek.utms.auth.dto.AuthTokenResponse; +import id.iptek.utms.auth.dto.LoginRequest; +import id.iptek.utms.auth.dto.RefreshRequest; +import id.iptek.utms.auth.repository.RefreshTokenRepository; +import id.iptek.utms.auth.repository.UserRepository; +import id.iptek.utms.auth.config.LdapProperties; +import id.iptek.utms.auth.security.JwtService; +import id.iptek.utms.auth.security.UserPrincipal; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.tenant.TenantContext; +import id.iptek.utms.tenant.TenantService; +import io.jsonwebtoken.Claims; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +@Service +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final TokenBlacklistService tokenBlacklistService; + private final TenantService tenantService; + private final LoginThrottleService loginThrottleService; + private final SingleLoginSessionService singleLoginSessionService; + private final MessageResolver messageResolver; + private final LdapProperties ldapProperties; + + public AuthService(AuthenticationManager authenticationManager, + JwtService jwtService, + RefreshTokenRepository refreshTokenRepository, + UserRepository userRepository, + TokenBlacklistService tokenBlacklistService, + TenantService tenantService, + LoginThrottleService loginThrottleService, + SingleLoginSessionService singleLoginSessionService, + MessageResolver messageResolver, + LdapProperties ldapProperties) { + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + this.refreshTokenRepository = refreshTokenRepository; + this.userRepository = userRepository; + this.tokenBlacklistService = tokenBlacklistService; + this.tenantService = tenantService; + this.loginThrottleService = loginThrottleService; + this.singleLoginSessionService = singleLoginSessionService; + this.messageResolver = messageResolver; + this.ldapProperties = ldapProperties; + } + + @Transactional + public AuthTokenResponse login(LoginRequest request) { + String tenantId = TenantContext.getRequiredTenantId(); + tenantService.getActiveTenant(tenantId); + loginThrottleService.ensureAllowed(tenantId, request.username()); + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password()) + ); + loginThrottleService.recordSuccess(tenantId, request.username()); + User user = resolveAuthenticatedUser(tenantId, authentication.getName()); + UserPrincipal principal = new UserPrincipal(user); + String sessionId = UUID.randomUUID().toString(); + String accessToken = jwtService.generateAccessToken(principal, sessionId); + String refreshToken = jwtService.generateRefreshToken(principal, sessionId); + + refreshTokenRepository.deleteByUser_IdAndTenantId(user.getId(), tenantId); + + RefreshToken entity = new RefreshToken(); + entity.setUser(user); + entity.setToken(refreshToken); + Instant refreshExpiresAt = jwtService.parseClaims(refreshToken).getExpiration().toInstant(); + entity.setExpiresAt(refreshExpiresAt); + entity.setRevoked(false); + entity.setTenantId(tenantId); + refreshTokenRepository.save(entity); + singleLoginSessionService.registerSession(tenantId, user.getUsername(), sessionId, refreshExpiresAt); + + return new AuthTokenResponse("Bearer", accessToken, refreshToken, jwtService.getAccessExpiresInSeconds()); + } catch (AppException ex) { + throw ex; + } catch (AuthenticationException ex) { + loginThrottleService.recordFailure(tenantId, request.username()); + throw new AppException(messageResolver.get("auth.invalid.credentials")); + } + } + + private User resolveAuthenticatedUser(String tenantId, String username) { + return userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseThrow(() -> new AppException(ldapProperties.enabled() + ? messageResolver.get("auth.user.notfound.for.ldap") + : messageResolver.get("auth.user.notfound"))); + } + + @Transactional + public AuthTokenResponse refresh(RefreshRequest request) { + String tenantId = TenantContext.getRequiredTenantId(); + RefreshToken refreshToken = refreshTokenRepository.findByTokenAndTenantId(request.refreshToken(), tenantId) + .orElseThrow(() -> new AppException(messageResolver.get("auth.refresh.notfound"))); + + if (refreshToken.isRevoked() || refreshToken.getExpiresAt().isBefore(Instant.now())) { + throw new AppException(messageResolver.get("auth.refresh.invalid")); + } + + Claims claims = jwtService.parseClaims(request.refreshToken()); + String username = claims.getSubject(); + User user = userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseThrow(() -> new AppException(messageResolver.get("auth.user.notfound"))); + + String tokenSessionId = jwtService.extractSessionId(request.refreshToken()); + if (singleLoginSessionService.isEnabled() && !singleLoginSessionService.isSessionActive(tenantId, username, tokenSessionId)) { + throw new AppException(messageResolver.get("auth.single.login.invalid_session")); + } + + UserPrincipal principal = new UserPrincipal(user); + String accessToken = jwtService.generateAccessToken(principal, tokenSessionId); + + return new AuthTokenResponse("Bearer", accessToken, request.refreshToken(), jwtService.getAccessExpiresInSeconds()); + } + + @Transactional + public void logout(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + return; + } + Claims claims = jwtService.parseClaims(accessToken); + Instant expiry = claims.getExpiration().toInstant(); + String tenantId = claims.get("tenant", String.class); + String username = claims.getSubject(); + String sessionId = jwtService.extractSessionId(accessToken); + singleLoginSessionService.clearSession(tenantId, username, sessionId); + Duration ttl = Duration.between(Instant.now(), expiry); + if (!ttl.isNegative() && !ttl.isZero()) { + tokenBlacklistService.blacklist(accessToken, ttl); + } + } +} + diff --git a/src/main/java/id/iptek/utms/auth/service/LoginThrottleService.java b/src/main/java/id/iptek/utms/auth/service/LoginThrottleService.java new file mode 100644 index 0000000..e7c5d50 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/LoginThrottleService.java @@ -0,0 +1,84 @@ +package id.iptek.utms.auth.service; + +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.core.i18n.MessageResolver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; + +@Service +public class LoginThrottleService { + + private final StringRedisTemplate redisTemplate; + private final MessageResolver messageResolver; + private final int maxFailedAttempts; + private final int failedAttemptWindowSeconds; + private final int lockoutDurationSeconds; + + private static final String FAIL_PREFIX = "utms:auth:login:fail:"; + private static final String LOCK_PREFIX = "utms:auth:login:lock:"; + + public LoginThrottleService(StringRedisTemplate redisTemplate, + MessageResolver messageResolver, + @Value("${app.security.login.max-failed-attempts:5}") int maxFailedAttempts, + @Value("${app.security.login.failed-attempt-window-seconds:900}") int failedAttemptWindowSeconds, + @Value("${app.security.login.lockout-duration-seconds:300}") int lockoutDurationSeconds) { + this.redisTemplate = redisTemplate; + this.messageResolver = messageResolver; + this.maxFailedAttempts = maxFailedAttempts; + this.failedAttemptWindowSeconds = failedAttemptWindowSeconds; + this.lockoutDurationSeconds = lockoutDurationSeconds; + } + + public void ensureAllowed(String tenantId, String username) { + String lockKey = lockKey(tenantId, username); + Long ttl = redisTemplate.getExpire(lockKey); + if (ttl == null || ttl < 1) { + redisTemplate.delete(lockKey); + return; + } + + throw new AppException(messageResolver.get("auth.login.locked", Math.max(ttl, 0L))); + } + + public void recordFailure(String tenantId, String username) { + String failKey = failKey(tenantId, username); + String lockKey = lockKey(tenantId, username); + + Long attempts = Optional.ofNullable(redisTemplate.opsForValue().increment(failKey)).orElse(1L); + if (attempts == 1) { + redisTemplate.expire(failKey, Duration.ofSeconds(failedAttemptWindowSeconds)); + } + + if (attempts >= maxFailedAttempts) { + redisTemplate.opsForValue().set(lockKey, "locked"); + redisTemplate.expire(lockKey, Duration.ofSeconds(lockoutDurationSeconds)); + redisTemplate.delete(failKey); + long ttl = redisTemplate.getExpire(lockKey); + throw new AppException(messageResolver.get("auth.login.locked", Math.max(ttl, (long) lockoutDurationSeconds))); + } + } + + public void recordSuccess(String tenantId, String username) { + redisTemplate.delete(failKey(tenantId, username)); + redisTemplate.delete(lockKey(tenantId, username)); + } + + private String failKey(String tenantId, String username) { + return FAIL_PREFIX + normalize(tenantId) + ":" + normalize(username); + } + + private String lockKey(String tenantId, String username) { + return LOCK_PREFIX + normalize(tenantId) + ":" + normalize(username); + } + + private String normalize(String value) { + return URLEncoder.encode(value.toLowerCase(Locale.ROOT), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/id/iptek/utms/auth/service/SingleLoginSessionService.java b/src/main/java/id/iptek/utms/auth/service/SingleLoginSessionService.java new file mode 100644 index 0000000..7652536 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/SingleLoginSessionService.java @@ -0,0 +1,80 @@ +package id.iptek.utms.auth.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; + +@Service +public class SingleLoginSessionService { + + private static final String SESSION_KEY_PREFIX = "utms:auth:single-login:"; + + private final StringRedisTemplate redisTemplate; + private final boolean enabled; + + public SingleLoginSessionService(StringRedisTemplate redisTemplate, + @Value("${app.security.single-login.enabled:false}") boolean enabled) { + this.redisTemplate = redisTemplate; + this.enabled = enabled; + } + + public void registerSession(String tenantId, String username, String sessionId, Instant sessionExpiresAt) { + if (!enabled) { + return; + } + String key = sessionKey(tenantId, username); + Duration ttl = ttlUntil(sessionExpiresAt); + redisTemplate.opsForValue().set(key, sessionId, ttl); + } + + public boolean isSessionActive(String tenantId, String username, String sessionId) { + if (!enabled) { + return true; + } + + if (tenantId == null || username == null || sessionId == null || tenantId.isBlank() || username.isBlank()) { + return false; + } + + String activeSession = redisTemplate.opsForValue().get(sessionKey(tenantId, username)); + return sessionId.equals(activeSession); + } + + public void clearSession(String tenantId, String username, String sessionId) { + if (!enabled || tenantId == null || username == null || tenantId.isBlank() || username.isBlank()) { + return; + } + + String key = sessionKey(tenantId, username); + String activeSession = redisTemplate.opsForValue().get(key); + if (sessionId == null || sessionId.equals(activeSession)) { + redisTemplate.delete(key); + } + } + + public void clearAllSessions(String tenantId, String username) { + if (!enabled || tenantId == null || username == null || tenantId.isBlank() || username.isBlank()) { + return; + } + redisTemplate.delete(sessionKey(tenantId, username)); + } + + public boolean isEnabled() { + return enabled; + } + + private Duration ttlUntil(Instant expiresAt) { + long seconds = Duration.between(Instant.now(), expiresAt).getSeconds(); + if (seconds <= 0) { + return Duration.ofSeconds(1); + } + return Duration.ofSeconds(seconds); + } + + private String sessionKey(String tenantId, String username) { + return SESSION_KEY_PREFIX + tenantId + ":" + username; + } +} diff --git a/src/main/java/id/iptek/utms/auth/service/TokenBlacklistService.java b/src/main/java/id/iptek/utms/auth/service/TokenBlacklistService.java new file mode 100644 index 0000000..7863134 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/TokenBlacklistService.java @@ -0,0 +1,27 @@ +package id.iptek.utms.auth.service; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class TokenBlacklistService { + + private static final String KEY_PREFIX = "auth:blacklist:"; + + private final StringRedisTemplate redisTemplate; + + public TokenBlacklistService(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void blacklist(String token, Duration ttl) { + redisTemplate.opsForValue().set(KEY_PREFIX + token, "1", ttl); + } + + public boolean isBlacklisted(String token) { + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + token)); + } +} + diff --git a/src/main/java/id/iptek/utms/auth/service/UserRoleManagementService.java b/src/main/java/id/iptek/utms/auth/service/UserRoleManagementService.java new file mode 100644 index 0000000..56fb1a9 --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/UserRoleManagementService.java @@ -0,0 +1,413 @@ +package id.iptek.utms.auth.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import id.iptek.utms.auth.domain.Permission; +import id.iptek.utms.auth.domain.Role; +import id.iptek.utms.auth.domain.User; +import id.iptek.utms.auth.domain.AuthenticationSource; +import id.iptek.utms.auth.config.LdapProperties; +import id.iptek.utms.auth.dto.CreateRoleManagementRequest; +import id.iptek.utms.auth.dto.CreateUserManagementRequest; +import id.iptek.utms.auth.dto.UpdateRolePermissionsRequest; +import id.iptek.utms.auth.dto.UpdateUserRolesRequest; +import id.iptek.utms.auth.repository.PermissionRepository; +import id.iptek.utms.auth.repository.RoleRepository; +import id.iptek.utms.auth.repository.UserRepository; +import id.iptek.utms.core.audit.service.AuditTrailService; +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.messaging.ApprovalCompletedEvent; +import id.iptek.utms.tenant.TenantContext; +import id.iptek.utms.workflow.domain.ApprovalRequest; +import id.iptek.utms.workflow.dto.ApprovalResponse; +import id.iptek.utms.workflow.repository.ApprovalRequestRepository; +import id.iptek.utms.workflow.service.ApprovalWorkflowService; +import id.iptek.utms.workflow.domain.ApprovalStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import jakarta.servlet.http.HttpServletRequest; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class UserRoleManagementService { + + public static final String CHECKER_ROLE_MANAGER = "USER_ROLE_ADMIN"; + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PermissionRepository permissionRepository; + private final ApprovalRequestRepository approvalRequestRepository; + private final ApprovalWorkflowService approvalWorkflowService; + private final AuditTrailService auditTrailService; + private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + private final LdapProperties ldapProperties; + + public UserRoleManagementService(UserRepository userRepository, + RoleRepository roleRepository, + PermissionRepository permissionRepository, + ApprovalRequestRepository approvalRequestRepository, + ApprovalWorkflowService approvalWorkflowService, + AuditTrailService auditTrailService, + PasswordEncoder passwordEncoder, + ObjectMapper objectMapper, + LdapProperties ldapProperties) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.permissionRepository = permissionRepository; + this.approvalRequestRepository = approvalRequestRepository; + this.approvalWorkflowService = approvalWorkflowService; + this.auditTrailService = auditTrailService; + this.passwordEncoder = passwordEncoder; + this.objectMapper = objectMapper; + this.ldapProperties = ldapProperties; + } + + @Transactional + public ApprovalResponse submitCreateUserRequest(CreateUserManagementRequest request, HttpServletRequest servletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + assertUserNotExists(tenantId, request.username()); + Set roles = resolveRoles(tenantId, request.roleCodes()); + + if (!ldapProperties.enabled() && !request.hasPassword()) { + throw new AppException("Password is required when LDAP is disabled"); + } + + Map payload = new LinkedHashMap<>(); + payload.put("operation", "CREATE_USER"); + payload.put("username", request.username()); + if (!ldapProperties.enabled()) { + payload.put("passwordHash", passwordEncoder.encode(request.password())); + } + payload.put("authSource", ldapProperties.enabled() ? AuthenticationSource.LDAP.name() : AuthenticationSource.LOCAL.name()); + payload.put("ldapDn", request.normalizedLdapDn()); + payload.put("enabled", request.isEnabled()); + payload.put("roleCodes", request.roleCodes()); + + return approvalWorkflowService.createRequest( + "USER_MANAGEMENT", + request.username(), + toJson(payload), + 1, + CHECKER_ROLE_MANAGER, + servletRequest + ); + } + + @Transactional + public ApprovalResponse submitUpdateUserRolesRequest(UpdateUserRolesRequest request, HttpServletRequest servletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + User target = userRepository.findByTenantIdAndUsername(tenantId, request.username()) + .orElseThrow(() -> new AppException("User not found")); + + Set roles = resolveRoles(tenantId, request.roleCodes()); + + Map payload = new LinkedHashMap<>(); + payload.put("operation", "UPDATE_USER_ROLES"); + payload.put("userId", target.getId().toString()); + payload.put("username", request.username()); + payload.put("roleCodes", request.roleCodes()); + + return approvalWorkflowService.createRequest( + "USER_MANAGEMENT", + request.username(), + toJson(payload), + 1, + CHECKER_ROLE_MANAGER, + servletRequest + ); + } + + @Transactional + public ApprovalResponse submitCreateRoleRequest(CreateRoleManagementRequest request, HttpServletRequest servletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + if (roleRepository.findByTenantIdAndCode(tenantId, request.code()).isPresent()) { + throw new AppException("Role already exists"); + } + resolvePermissions(tenantId, request.permissionCodes()); + + Map payload = new LinkedHashMap<>(); + payload.put("operation", "CREATE_ROLE"); + payload.put("code", request.code()); + payload.put("name", request.name()); + payload.put("permissionCodes", request.permissionCodes()); + + return approvalWorkflowService.createRequest( + "ROLE_MANAGEMENT", + request.code(), + toJson(payload), + 1, + CHECKER_ROLE_MANAGER, + servletRequest + ); + } + + @Transactional + public ApprovalResponse submitUpdateRolePermissionsRequest(UpdateRolePermissionsRequest request, HttpServletRequest servletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + roleRepository.findByTenantIdAndCode(tenantId, request.code()) + .orElseThrow(() -> new AppException("Role not found")); + resolvePermissions(tenantId, request.permissionCodes()); + + Map payload = new LinkedHashMap<>(); + payload.put("operation", "UPDATE_ROLE_PERMISSIONS"); + payload.put("code", request.code()); + payload.put("permissionCodes", request.permissionCodes()); + + return approvalWorkflowService.createRequest( + "ROLE_MANAGEMENT", + request.code(), + toJson(payload), + 1, + CHECKER_ROLE_MANAGER, + servletRequest + ); + } + + @Transactional + public void applyApprovedRequest(ApprovalCompletedEvent event) { + ApprovalRequest approvalRequest = approvalRequestRepository + .findByIdAndTenantId(event.requestId(), event.tenantId()) + .orElse(null); + if (approvalRequest == null) { + return; + } + if (approvalRequest.getStatus() != ApprovalStatus.APPROVED) { + return; + } + + String tenantId = event.tenantId(); + try { + TenantContext.setTenantId(tenantId); + Map payload = objectMapper.readValue(approvalRequest.getPayload(), new TypeReference<>() {}); + String operation = payload.get("operation") != null ? payload.get("operation").toString() : ""; + switch (operation) { + case "CREATE_USER" -> applyCreateUser(approvalRequest, payload, tenantId, event); + case "UPDATE_USER_ROLES" -> applyUpdateUserRoles(approvalRequest, payload, tenantId, event); + case "CREATE_ROLE" -> applyCreateRole(approvalRequest, payload, tenantId, event); + case "UPDATE_ROLE_PERMISSIONS" -> applyUpdateRolePermissions(approvalRequest, payload, tenantId, event); + default -> { + auditTrailService.record( + "USER_ROLE_MANAGEMENT_UNKNOWN", + "WORKFLOW", + approvalRequest.getResourceType(), + approvalRequest.getId().toString(), + AuditTrailService.FAILURE, + "Unknown workflow operation", + null, + null, + null, + null + ); + throw new AppException("Unknown user/role management operation"); + } + } + } catch (Exception ex) { + throw new AppException("Failed to apply approved management request: " + ex.getMessage()); + } finally { + TenantContext.clear(); + } + } + + @Transactional + protected void applyCreateUser(ApprovalRequest approvalRequest, Map payload, String tenantId, ApprovalCompletedEvent event) { + String username = (String) payload.get("username"); + String authSourceName = (String) payload.get("authSource"); + AuthenticationSource authSource = parseAuthSource(authSourceName, "LOCAL"); + boolean enabled = asBoolean(payload.get("enabled"), true); + List roleCodes = castToStringList(payload.get("roleCodes")); + Set roles = resolveRoles(tenantId, roleCodes); + String ldapDn = (String) payload.get("ldapDn"); + + User user = userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseGet(() -> { + User created = new User(); + created.setTenantId(tenantId); + created.setUsername(username); + return created; + }); + + Map before = snapshotUser(user); + if (authSource == AuthenticationSource.LOCAL) { + String passwordHash = (String) payload.get("passwordHash"); + if (passwordHash == null) { + throw new AppException("Local user creation requires password hash"); + } + user.setPassword(passwordHash); + } else { + user.setPassword(null); + user.setLdapDn(ldapDn); + } + user.setAuthSource(authSource); + user.setEnabled(enabled); + user.setRoles(new LinkedHashSet<>(roles)); + User after = userRepository.save(user); + recordManagementTrail("USER_CREATE_APPLY", approvalRequest, event.approvedBy(), before, snapshotUser(after), tenantId); + } + + @Transactional + protected void applyUpdateUserRoles(ApprovalRequest approvalRequest, Map payload, String tenantId, ApprovalCompletedEvent event) { + String username = (String) payload.get("username"); + User user = userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseThrow(() -> new AppException("User not found")); + List roleCodes = castToStringList(payload.get("roleCodes")); + Set roles = resolveRoles(tenantId, roleCodes); + Map before = snapshotUser(user); + user.setRoles(new LinkedHashSet<>(roles)); + User after = userRepository.save(user); + recordManagementTrail("USER_UPDATE_ROLES_APPLY", approvalRequest, event.approvedBy(), before, snapshotUser(after), tenantId); + } + + @Transactional + protected void applyCreateRole(ApprovalRequest approvalRequest, Map payload, String tenantId, ApprovalCompletedEvent event) { + String code = (String) payload.get("code"); + String name = (String) payload.get("name"); + List permissionCodes = castToStringList(payload.get("permissionCodes")); + Set permissions = resolvePermissions(tenantId, permissionCodes); + + Role role = roleRepository.findByTenantIdAndCode(tenantId, code) + .orElseGet(() -> { + Role created = new Role(); + created.setTenantId(tenantId); + created.setCode(code); + created.setName(name); + created.setPermissions(permissions); + return roleRepository.save(created); + }); + + Map before = snapshotRole(role); + role.setName(name); + role.setPermissions(permissions); + Role after = roleRepository.save(role); + recordManagementTrail("ROLE_CREATE_APPLY", approvalRequest, event.approvedBy(), before, snapshotRole(after), tenantId); + } + + @Transactional + protected void applyUpdateRolePermissions(ApprovalRequest approvalRequest, Map payload, String tenantId, ApprovalCompletedEvent event) { + String code = (String) payload.get("code"); + List permissionCodes = castToStringList(payload.get("permissionCodes")); + Role role = roleRepository.findByTenantIdAndCode(tenantId, code) + .orElseThrow(() -> new AppException("Role not found")); + + Set permissions = resolvePermissions(tenantId, permissionCodes); + Map before = snapshotRole(role); + role.setPermissions(permissions); + Role after = roleRepository.save(role); + recordManagementTrail("ROLE_UPDATE_PERMISSIONS_APPLY", approvalRequest, event.approvedBy(), before, snapshotRole(after), tenantId); + } + + private void recordManagementTrail(String action, + ApprovalRequest approvalRequest, + String actor, + Map before, + Map after, + String tenantId) { + TenantContext.setTenantId(tenantId); + auditTrailService.record( + action, + "AUTH", + approvalRequest.getResourceType(), + approvalRequest.getResourceId(), + AuditTrailService.SUCCESS, + "Management request applied", + auditTrailService.toJson(before), + auditTrailService.toJson(after), + null, + null + ); + } + + private void assertUserNotExists(String tenantId, String username) { + if (userRepository.existsByTenantIdAndUsername(tenantId, username)) { + throw new AppException("User already exists"); + } + } + + private AuthenticationSource parseAuthSource(String source, String fallback) { + if (source == null || source.isBlank()) { + return AuthenticationSource.valueOf(fallback); + } + try { + return AuthenticationSource.valueOf(source); + } catch (IllegalArgumentException ex) { + return AuthenticationSource.valueOf(fallback); + } + } + + private Set resolveRoles(String tenantId, Collection roleCodes) { + if (CollectionUtils.isEmpty(roleCodes)) { + return Set.of(); + } + Set roles = roleRepository.findByTenantIdAndCodeIn(tenantId, roleCodes) + .stream().collect(Collectors.toCollection(LinkedHashSet::new)); + if (roles.size() != roleCodes.size()) { + throw new AppException("Some role codes are invalid"); + } + return roles; + } + + private Set resolvePermissions(String tenantId, Collection permissionCodes) { + if (CollectionUtils.isEmpty(permissionCodes)) { + return Set.of(); + } + List permissions = permissionRepository.findByTenantIdAndCodeIn(tenantId, permissionCodes); + if (permissions.size() != permissionCodes.size()) { + throw new AppException("Some permission codes are invalid"); + } + return new LinkedHashSet<>(permissions); + } + + private List castToStringList(Object raw) { + if (!(raw instanceof Collection values) || CollectionUtils.isEmpty(values)) { + return List.of(); + } + return values.stream() + .map(String::valueOf) + .toList(); + } + + private Map snapshotUser(User user) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", user.getId() != null ? user.getId().toString() : null); + snapshot.put("tenantId", user.getTenantId()); + snapshot.put("username", user.getUsername()); + snapshot.put("authSource", user.getAuthSource()); + snapshot.put("ldapDn", user.getLdapDn()); + snapshot.put("enabled", user.isEnabled()); + snapshot.put("roles", user.getRoles().stream() + .map(Role::getCode) + .sorted() + .toList()); + return snapshot; + } + + private Map snapshotRole(Role role) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", role.getId() != null ? role.getId().toString() : null); + snapshot.put("tenantId", role.getTenantId()); + snapshot.put("code", role.getCode()); + snapshot.put("name", role.getName()); + snapshot.put("permissions", role.getPermissions().stream() + .map(Permission::getCode) + .sorted() + .toList()); + return snapshot; + } + + private boolean asBoolean(Object value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + if (value instanceof Boolean bool) { + return bool; + } + return Boolean.parseBoolean(value.toString()); + } + + private String toJson(Map payload) { + return auditTrailService.toJson(payload); + } +} diff --git a/src/main/java/id/iptek/utms/auth/service/UserService.java b/src/main/java/id/iptek/utms/auth/service/UserService.java new file mode 100644 index 0000000..772149a --- /dev/null +++ b/src/main/java/id/iptek/utms/auth/service/UserService.java @@ -0,0 +1,37 @@ +package id.iptek.utms.auth.service; + +import id.iptek.utms.auth.domain.Role; +import id.iptek.utms.auth.domain.User; +import id.iptek.utms.auth.dto.CurrentUserResponse; +import id.iptek.utms.auth.repository.UserRepository; +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.tenant.TenantContext; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public CurrentUserResponse me(String username) { + String tenantId = TenantContext.getRequiredTenantId(); + User user = userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseThrow(() -> new AppException("User not found")); + + Set roleCodes = user.getRoles().stream().map(Role::getCode).collect(Collectors.toSet()); + Set permissions = user.getRoles().stream() + .flatMap(role -> role.getPermissions().stream()) + .map(permission -> permission.getCode()) + .collect(Collectors.toSet()); + + return new CurrentUserResponse(tenantId, user.getUsername(), roleCodes, permissions); + } +} + diff --git a/src/main/java/id/iptek/utms/core/audit/domain/AuditTrail.java b/src/main/java/id/iptek/utms/core/audit/domain/AuditTrail.java new file mode 100644 index 0000000..28b2bef --- /dev/null +++ b/src/main/java/id/iptek/utms/core/audit/domain/AuditTrail.java @@ -0,0 +1,69 @@ +package id.iptek.utms.core.audit.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table( + name = "sys_audit_trails", + indexes = { + @Index(name = "sys_idx_audit_tenant_created", columnList = "tenant_id,created_at"), + @Index(name = "sys_idx_audit_correlation", columnList = "correlation_id"), + @Index(name = "sys_idx_audit_actor", columnList = "actor"), + @Index(name = "sys_idx_audit_action", columnList = "action") + } +) +public class AuditTrail extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "actor", nullable = false, length = 255) + private String actor; + + @Column(name = "action", nullable = false, length = 100) + private String action; + + @Column(name = "correlation_id", length = 100) + private String correlationId; + + @Column(name = "domain", length = 100) + private String domain; + + @Column(name = "resource_type", length = 100) + private String resourceType; + + @Column(name = "resource_id", length = 255) + private String resourceId; + + @Column(name = "outcome", nullable = false, length = 20) + private String outcome; + + @Column(name = "http_method", length = 20) + private String httpMethod; + + @Column(name = "request_path", length = 500) + private String requestPath; + + @Column(name = "client_ip", length = 80) + private String clientIp; + + @Column(name = "error_message", length = 1000) + private String errorMessage; + + @Column(name = "details", columnDefinition = "text") + private String details; + + @Column(name = "before_state", columnDefinition = "text") + private String beforeState; + + @Column(name = "after_state", columnDefinition = "text") + private String afterState; +} diff --git a/src/main/java/id/iptek/utms/core/audit/dto/AuditTrailResponse.java b/src/main/java/id/iptek/utms/core/audit/dto/AuditTrailResponse.java new file mode 100644 index 0000000..8e794ae --- /dev/null +++ b/src/main/java/id/iptek/utms/core/audit/dto/AuditTrailResponse.java @@ -0,0 +1,23 @@ +package id.iptek.utms.core.audit.dto; + +import java.time.Instant; +import java.util.UUID; + +public record AuditTrailResponse( + UUID id, + String tenantId, + String actor, + String correlationId, + String action, + String domain, + String resourceType, + String resourceId, + String outcome, + String httpMethod, + String requestPath, + String errorMessage, + String beforeState, + String afterState, + String details, + Instant createdAt +) {} diff --git a/src/main/java/id/iptek/utms/core/audit/repository/AuditTrailRepository.java b/src/main/java/id/iptek/utms/core/audit/repository/AuditTrailRepository.java new file mode 100644 index 0000000..15fe34e --- /dev/null +++ b/src/main/java/id/iptek/utms/core/audit/repository/AuditTrailRepository.java @@ -0,0 +1,12 @@ +package id.iptek.utms.core.audit.repository; + +import id.iptek.utms.core.audit.domain.AuditTrail; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AuditTrailRepository extends JpaRepository { + Page findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable); +} diff --git a/src/main/java/id/iptek/utms/core/audit/service/AuditTrailService.java b/src/main/java/id/iptek/utms/core/audit/service/AuditTrailService.java new file mode 100644 index 0000000..24ef69a --- /dev/null +++ b/src/main/java/id/iptek/utms/core/audit/service/AuditTrailService.java @@ -0,0 +1,146 @@ +package id.iptek.utms.core.audit.service; + +import id.iptek.utms.core.audit.domain.AuditTrail; +import id.iptek.utms.core.audit.repository.AuditTrailRepository; +import id.iptek.utms.core.security.SecurityUtils; +import id.iptek.utms.tenant.TenantContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Service +public class AuditTrailService { + + public static final String SUCCESS = "SUCCESS"; + public static final String FAILURE = "FAILURE"; + public static final String CORRELATION_ID_ATTRIBUTE = "UTMS_AUDIT_CORRELATION_ID"; + + private final AuditTrailRepository auditTrailRepository; + private final ObjectMapper objectMapper; + + public AuditTrailService(AuditTrailRepository auditTrailRepository, ObjectMapper objectMapper) { + this.auditTrailRepository = auditTrailRepository; + this.objectMapper = objectMapper; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String action, + String domain, + String resourceType, + String resourceId, + String outcome, + String details, + String errorMessage, + HttpServletRequest request) { + record(action, domain, resourceType, resourceId, outcome, details, null, null, null, errorMessage, request); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String action, + String domain, + String resourceType, + String resourceId, + String outcome, + String details, + String beforeState, + String afterState, + String errorMessage, + HttpServletRequest request) { + String correlationId = resolveOrCreateCorrelationId(request); + record(action, domain, resourceType, resourceId, outcome, details, beforeState, afterState, correlationId, errorMessage, request); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String action, + String domain, + String resourceType, + String resourceId, + String outcome, + String details, + String beforeState, + String afterState, + String correlationId, + String errorMessage, + HttpServletRequest request) { + String tenantId = TenantContext.getTenantId() != null ? TenantContext.getTenantId() : "system"; + String actor = SecurityUtils.currentUsername(); + if (actor == null || actor.isBlank()) { + actor = "anonymous"; + } + + AuditTrail trail = new AuditTrail(); + trail.setTenantId(tenantId); + trail.setCorrelationId(correlationId); + trail.setActor(actor); + trail.setAction(action); + trail.setDomain(domain); + trail.setResourceType(resourceType); + trail.setResourceId(resourceId); + trail.setOutcome(outcome != null ? outcome : SUCCESS); + trail.setErrorMessage(errorMessage); + trail.setDetails(details); + trail.setBeforeState(beforeState); + trail.setAfterState(afterState); + + if (request != null) { + trail.setHttpMethod(request.getMethod()); + trail.setRequestPath(request.getRequestURI()); + trail.setClientIp(request.getRemoteAddr()); + } + + auditTrailRepository.save(trail); + } + + private String resolveOrCreateCorrelationId(HttpServletRequest request) { + if (request == null) { + return java.util.UUID.randomUUID().toString(); + } + Object existing = request.getAttribute(CORRELATION_ID_ATTRIBUTE); + if (existing instanceof String existingValue && !existingValue.isBlank()) { + return existingValue; + } + String created = java.util.UUID.randomUUID().toString(); + request.setAttribute(CORRELATION_ID_ATTRIBUTE, created); + return created; + } + + public String toJson(Object value) { + if (value == null) { + return null; + } + try { + if (value instanceof String stringValue) { + return stringValue; + } + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + return objectMapper.createObjectNode() + .put("type", value.getClass().getName()) + .put("value", String.valueOf(value)) + .toString(); + } + } + + public String toChangeSummary(String event, Map before, Map after) { + return toJson(Map.of( + "event", event, + "before", before, + "after", after + )); + } + + public List listRecent(String tenantId, int limit) { + int normalized = Math.max(1, Math.min(limit, 500)); + return auditTrailRepository.findByTenantIdOrderByCreatedAtDesc( + tenantId, + PageRequest.of(0, normalized, Sort.by(Sort.Direction.DESC, "createdAt")) + ).getContent(); + } +} diff --git a/src/main/java/id/iptek/utms/core/config/ActiveMqConfig.java b/src/main/java/id/iptek/utms/core/config/ActiveMqConfig.java new file mode 100644 index 0000000..99b903b --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/ActiveMqConfig.java @@ -0,0 +1,10 @@ +package id.iptek.utms.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.annotation.EnableJms; + +@EnableJms +@Configuration +public class ActiveMqConfig { +} + diff --git a/src/main/java/id/iptek/utms/core/config/AuditLoggingAspect.java b/src/main/java/id/iptek/utms/core/config/AuditLoggingAspect.java new file mode 100644 index 0000000..72eef67 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/AuditLoggingAspect.java @@ -0,0 +1,72 @@ +package id.iptek.utms.core.config; + +import id.iptek.utms.core.security.SecurityUtils; +import id.iptek.utms.core.audit.service.AuditTrailService; +import id.iptek.utms.tenant.TenantContext; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +public class AuditLoggingAspect { + + private static final Logger log = LoggerFactory.getLogger(AuditLoggingAspect.class); + private final AuditTrailService auditTrailService; + + public AuditLoggingAspect(AuditTrailService auditTrailService) { + this.auditTrailService = auditTrailService; + } + + @Around("execution(* id.iptek.utms..controller..*(..))") + public Object auditControllerCalls(ProceedingJoinPoint joinPoint) throws Throwable { + String user = SecurityUtils.currentUsername(); + String tenant = TenantContext.getTenantId() != null ? TenantContext.getTenantId() : "system"; + String method = joinPoint.getSignature().toShortString(); + HttpServletRequest request = currentRequest(); + + log.info("AUDIT START method={} user={} tenant={}", method, user, tenant); + try { + Object result = joinPoint.proceed(); + log.info("AUDIT SUCCESS method={} user={} tenant={}", method, user, tenant); + auditTrailService.record( + method, + "CONTROLLER", + null, + null, + AuditTrailService.SUCCESS, + "Controller invocation completed", + null, + request + ); + return result; + } catch (Throwable t) { + log.warn("AUDIT FAIL method={} user={} tenant={} error={}", method, user, tenant, t.getMessage()); + auditTrailService.record( + method, + "CONTROLLER", + null, + null, + AuditTrailService.FAILURE, + "Controller invocation failed", + t.getMessage(), + request + ); + throw t; + } + } + + private HttpServletRequest currentRequest() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { + return attributes.getRequest(); + } + return null; + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/DataSeeder.java b/src/main/java/id/iptek/utms/core/config/DataSeeder.java new file mode 100644 index 0000000..1b13f99 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/DataSeeder.java @@ -0,0 +1,183 @@ +package id.iptek.utms.core.config; + +import id.iptek.utms.auth.domain.Permission; +import id.iptek.utms.auth.domain.Role; +import id.iptek.utms.auth.domain.User; +import id.iptek.utms.auth.repository.PermissionRepository; +import id.iptek.utms.auth.repository.RoleRepository; +import id.iptek.utms.auth.repository.UserRepository; +import id.iptek.utms.module.domain.SystemModule; +import id.iptek.utms.module.repository.SystemModuleRepository; +import id.iptek.utms.tenant.Tenant; +import id.iptek.utms.tenant.TenantContext; +import id.iptek.utms.tenant.TenantRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@Profile("dev") +public class DataSeeder implements CommandLineRunner { + + private final TenantRepository tenantRepository; + private final PermissionRepository permissionRepository; + private final RoleRepository roleRepository; + private final UserRepository userRepository; + private final SystemModuleRepository moduleRepository; + private final PasswordEncoder passwordEncoder; + private final boolean seedEnabled; + + public DataSeeder(TenantRepository tenantRepository, + PermissionRepository permissionRepository, + RoleRepository roleRepository, + UserRepository userRepository, + SystemModuleRepository moduleRepository, + PasswordEncoder passwordEncoder, + @org.springframework.beans.factory.annotation.Value("${app.seed.enabled:false}") boolean seedEnabled) { + this.tenantRepository = tenantRepository; + this.permissionRepository = permissionRepository; + this.roleRepository = roleRepository; + this.userRepository = userRepository; + this.moduleRepository = moduleRepository; + this.passwordEncoder = passwordEncoder; + this.seedEnabled = seedEnabled; + } + + @Override + @Transactional + public void run(String... args) { + if (!seedEnabled) { + return; + } + seedTenantData("acme", "Acme Corporation"); + seedTenantData("test", "Test Tenant"); + } + + private void seedTenantData(String tenantId, String tenantName) { + Tenant tenant = tenantRepository.findByTenantIdAndActiveTrue(tenantId).orElseGet(() -> { + Tenant t = new Tenant(); + t.setTenantId(tenantId); + t.setName(tenantName); + t.setActive(true); + return tenantRepository.save(t); + }); + + TenantContext.setTenantId(tenant.getTenantId()); + try { + Permission userRead = getOrCreatePermission("USER_READ", "Read user profile"); + Permission userManage = getOrCreatePermission("USER_MANAGE", "Manage users"); + Permission roleManage = getOrCreatePermission("ROLE_MANAGE", "Manage roles"); + Permission workflowCreate = getOrCreatePermission("WORKFLOW_CREATE", "Create workflow request"); + Permission workflowApprove = getOrCreatePermission("WORKFLOW_APPROVE", "Approve workflow request"); + + // Legacy sample roles + Role maker = getOrCreateRole("MAKER", "Maker", Set.of(workflowCreate)); + Role checker = getOrCreateRole("CHECKER", "Checker", Set.of(workflowApprove)); + Role admin = getOrCreateRole("ADMIN", "Administrator", Set.of(userRead, workflowCreate, workflowApprove)); + + // Bootstrap manager role that can manage both user and role lifecycle via workflow + Role userRoleAdmin = getOrCreateRole( + "USER_ROLE_ADMIN", + "User & Role Administrator", + Set.of(userRead, userManage, roleManage, workflowCreate, workflowApprove) + ); + + getOrCreateUser("maker", "Passw0rd!", Set.of(maker)); + getOrCreateUser("checker", "Passw0rd!", Set.of(checker)); + getOrCreateUser("admin", "Passw0rd!", Set.of(admin)); + getOrCreateUser("system.manager", "Passw0rd!", Set.of(userRoleAdmin)); + getOrCreateUser("system.owner", "Passw0rd!", Set.of(admin, userRoleAdmin)); + + if ("acme".equals(tenantId)) { + getOrCreateUser("acme.owner", "Passw0rd!", Set.of(admin)); + getOrCreateModule("NOTIFICATION", "Notification Module", true); + getOrCreateModule("REPORTING", "Reporting Module", false); + getOrCreateModule("AUDIT", "Audit Trail Module", true); + } else { + getOrCreateModule("NOTIFICATION", "Notification Module", true); + } + } finally { + TenantContext.clear(); + } + } + + private Permission getOrCreatePermission(String code, String name) { + return permissionRepository.findByTenantIdAndCode(TenantContext.getRequiredTenantId(), code) + .orElseGet(() -> { + Permission p = new Permission(); + p.setCode(code); + p.setName(name); + p.setTenantId(TenantContext.getRequiredTenantId()); + return permissionRepository.save(p); + }); + } + + private Role getOrCreateRole(String code, String name, Set permissions) { + String tenantId = TenantContext.getRequiredTenantId(); + Role role = roleRepository.findByTenantIdAndCode(tenantId, code) + .orElseGet(() -> { + Role roleEntity = new Role(); + roleEntity.setCode(code); + roleEntity.setName(name); + roleEntity.setPermissions(new HashSet<>()); + roleEntity.setTenantId(tenantId); + return roleEntity; + }); + role.setName(name); + role.setTenantId(tenantId); + addMissingByCode(role.getPermissions(), permissions, Permission::getCode); + return roleRepository.save(role); + } + + private void getOrCreateUser(String username, String rawPassword, Set roles) { + String tenantId = TenantContext.getRequiredTenantId(); + User user = userRepository.findByTenantIdAndUsername(tenantId, username) + .orElseGet(() -> { + User created = new User(); + created.setUsername(username); + created.setPassword(passwordEncoder.encode(rawPassword)); + created.setEnabled(true); + created.setRoles(new HashSet<>()); + created.setTenantId(tenantId); + return created; + }); + + if (user.getId() == null) { + user.setPassword(passwordEncoder.encode(rawPassword)); + } + user.setTenantId(tenantId); + addMissingByCode(user.getRoles(), roles, Role::getCode); + userRepository.save(user); + } + + private void getOrCreateModule(String code, String name, boolean enabled) { + moduleRepository.findByTenantIdAndCode(TenantContext.getRequiredTenantId(), code) + .orElseGet(() -> { + SystemModule module = new SystemModule(); + module.setCode(code); + module.setName(name); + module.setEnabled(enabled); + module.setTenantId(TenantContext.getRequiredTenantId()); + return moduleRepository.save(module); + }); + } + + private void addMissingByCode(Collection target, Collection requested, java.util.function.Function codeExtractor) { + Set existingCodes = target.stream() + .map(codeExtractor) + .collect(Collectors.toSet()); + List missing = requested.stream() + .filter(item -> item != null && !existingCodes.contains(codeExtractor.apply(item))) + .toList(); + target.addAll(missing); + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/I18nConfig.java b/src/main/java/id/iptek/utms/core/config/I18nConfig.java new file mode 100644 index 0000000..c52b2c2 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/I18nConfig.java @@ -0,0 +1,20 @@ +package id.iptek.utms.core.config; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; + +@Configuration +public class I18nConfig { + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource(); + source.setBasename("classpath:i18n/messages"); + source.setDefaultEncoding("UTF-8"); + source.setFallbackToSystemLocale(false); + return source; + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/JpaAuditConfig.java b/src/main/java/id/iptek/utms/core/config/JpaAuditConfig.java new file mode 100644 index 0000000..355b6c6 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/JpaAuditConfig.java @@ -0,0 +1,20 @@ +package id.iptek.utms.core.config; + +import id.iptek.utms.core.security.SecurityUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.util.Optional; + +@Configuration +@EnableJpaAuditing +public class JpaAuditConfig { + + @Bean + public AuditorAware auditorAware() { + return () -> Optional.ofNullable(SecurityUtils.currentUsername()).or(() -> Optional.of("system")); + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/LocaleConfig.java b/src/main/java/id/iptek/utms/core/config/LocaleConfig.java new file mode 100644 index 0000000..6213506 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/LocaleConfig.java @@ -0,0 +1,20 @@ +package id.iptek.utms.core.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; + +import java.util.Locale; + +@Configuration +public class LocaleConfig { + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); + resolver.setDefaultLocale(Locale.ENGLISH); + return resolver; + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/OpenApiConfig.java b/src/main/java/id/iptek/utms/core/config/OpenApiConfig.java new file mode 100644 index 0000000..9bc11c6 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/OpenApiConfig.java @@ -0,0 +1,36 @@ +package id.iptek.utms.core.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + final String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .info(new Info() + .title("UTMS NG BE API") + .version("1.0.0") + .description("Authentication: click Authorize and paste only the raw JWT. UI will send 'Authorization: Bearer '.")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name("Authorization") + .type(Type.HTTP) + .in(In.HEADER) + .scheme("bearer") + .bearerFormat("JWT") + )); + } +} + diff --git a/src/main/java/id/iptek/utms/core/config/RedisConfig.java b/src/main/java/id/iptek/utms/core/config/RedisConfig.java new file mode 100644 index 0000000..3e45d65 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/config/RedisConfig.java @@ -0,0 +1,31 @@ +package id.iptek.utms.core.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .disableCachingNullValues(); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} + diff --git a/src/main/java/id/iptek/utms/core/domain/BaseEntity.java b/src/main/java/id/iptek/utms/core/domain/BaseEntity.java new file mode 100644 index 0000000..6446821 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/domain/BaseEntity.java @@ -0,0 +1,48 @@ +package id.iptek.utms.core.domain; + +import id.iptek.utms.tenant.TenantContext; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Column(name = "tenant_id", nullable = false, updatable = false) + private String tenantId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @CreatedBy + @Column(name = "created_by") + private String createdBy; + + @LastModifiedBy + @Column(name = "updated_by") + private String updatedBy; + + protected void applyTenantFromContext() { + if (tenantId == null) { + tenantId = TenantContext.getRequiredTenantId(); + } + } +} + diff --git a/src/main/java/id/iptek/utms/core/domain/TenantEntityListener.java b/src/main/java/id/iptek/utms/core/domain/TenantEntityListener.java new file mode 100644 index 0000000..b1662de --- /dev/null +++ b/src/main/java/id/iptek/utms/core/domain/TenantEntityListener.java @@ -0,0 +1,14 @@ +package id.iptek.utms.core.domain; + +import jakarta.persistence.PrePersist; + +public class TenantEntityListener { + + @PrePersist + public void prePersist(Object entity) { + if (entity instanceof BaseEntity baseEntity) { + baseEntity.applyTenantFromContext(); + } + } +} + diff --git a/src/main/java/id/iptek/utms/core/exception/AppException.java b/src/main/java/id/iptek/utms/core/exception/AppException.java new file mode 100644 index 0000000..bfebee9 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/exception/AppException.java @@ -0,0 +1,9 @@ +package id.iptek.utms.core.exception; + +public class AppException extends RuntimeException { + + public AppException(String message) { + super(message); + } +} + diff --git a/src/main/java/id/iptek/utms/core/exception/GlobalExceptionHandler.java b/src/main/java/id/iptek/utms/core/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1d7511b --- /dev/null +++ b/src/main/java/id/iptek/utms/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package id.iptek.utms.core.exception; + +import id.iptek.utms.api.ApiResponse; +import id.iptek.utms.core.i18n.MessageResolver; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final MessageResolver messageResolver; + + public GlobalExceptionHandler(MessageResolver messageResolver) { + this.messageResolver = messageResolver; + } + + @ExceptionHandler(AppException.class) + public ResponseEntity> handleAppException(AppException ex) { + return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(err -> err.getField() + " " + err.getDefaultMessage()) + .orElse(messageResolver.get("error.validation")); + return ResponseEntity.badRequest().body(ApiResponse.fail(errorMessage)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException ex) { + return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage())); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied() { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.fail(messageResolver.get("error.forbidden"))); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail(messageResolver.get("error.internal"))); + } +} + diff --git a/src/main/java/id/iptek/utms/core/i18n/MessageResolver.java b/src/main/java/id/iptek/utms/core/i18n/MessageResolver.java new file mode 100644 index 0000000..ba2f157 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/i18n/MessageResolver.java @@ -0,0 +1,20 @@ +package id.iptek.utms.core.i18n; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class MessageResolver { + + private final MessageSource messageSource; + + public MessageResolver(MessageSource messageSource) { + this.messageSource = messageSource; + } + + public String get(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } +} + diff --git a/src/main/java/id/iptek/utms/core/security/SecurityUtils.java b/src/main/java/id/iptek/utms/core/security/SecurityUtils.java new file mode 100644 index 0000000..30cc995 --- /dev/null +++ b/src/main/java/id/iptek/utms/core/security/SecurityUtils.java @@ -0,0 +1,19 @@ +package id.iptek.utms.core.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public final class SecurityUtils { + + private SecurityUtils() { + } + + public static String currentUsername() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return authentication.getName(); + } +} + diff --git a/src/main/java/id/iptek/utms/messaging/ApprovalCompletedEvent.java b/src/main/java/id/iptek/utms/messaging/ApprovalCompletedEvent.java new file mode 100644 index 0000000..45f6120 --- /dev/null +++ b/src/main/java/id/iptek/utms/messaging/ApprovalCompletedEvent.java @@ -0,0 +1,14 @@ +package id.iptek.utms.messaging; + +import java.io.Serializable; +import java.util.UUID; + +public record ApprovalCompletedEvent( + UUID requestId, + String tenantId, + String resourceType, + String resourceId, + String approvedBy +) implements Serializable { +} + diff --git a/src/main/java/id/iptek/utms/messaging/ApprovalEventConsumer.java b/src/main/java/id/iptek/utms/messaging/ApprovalEventConsumer.java new file mode 100644 index 0000000..bce7db7 --- /dev/null +++ b/src/main/java/id/iptek/utms/messaging/ApprovalEventConsumer.java @@ -0,0 +1,27 @@ +package id.iptek.utms.messaging; + +import id.iptek.utms.auth.service.UserRoleManagementService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jms.annotation.JmsListener; +import org.springframework.stereotype.Component; + +@Component +public class ApprovalEventConsumer { + + private static final Logger log = LoggerFactory.getLogger(ApprovalEventConsumer.class); + private final UserRoleManagementService userRoleManagementService; + + public ApprovalEventConsumer(UserRoleManagementService userRoleManagementService) { + this.userRoleManagementService = userRoleManagementService; + } + + @JmsListener(destination = "approval.completed.queue") + public void onApprovalCompleted(ApprovalCompletedEvent event) { + log.info("Received approval.completed event requestId={} tenant={} resourceType={}", + event.requestId(), event.tenantId(), event.resourceType()); + userRoleManagementService.applyApprovedRequest(event); + } + +} + diff --git a/src/main/java/id/iptek/utms/messaging/ApprovalEventProducer.java b/src/main/java/id/iptek/utms/messaging/ApprovalEventProducer.java new file mode 100644 index 0000000..3873654 --- /dev/null +++ b/src/main/java/id/iptek/utms/messaging/ApprovalEventProducer.java @@ -0,0 +1,19 @@ +package id.iptek.utms.messaging; + +import org.springframework.jms.core.JmsTemplate; +import org.springframework.stereotype.Component; + +@Component +public class ApprovalEventProducer { + + private final JmsTemplate jmsTemplate; + + public ApprovalEventProducer(JmsTemplate jmsTemplate) { + this.jmsTemplate = jmsTemplate; + } + + public void publishCompleted(ApprovalCompletedEvent event) { + jmsTemplate.convertAndSend("approval.completed.queue", event); + } +} + diff --git a/src/main/java/id/iptek/utms/module/controller/ModuleController.java b/src/main/java/id/iptek/utms/module/controller/ModuleController.java new file mode 100644 index 0000000..d611608 --- /dev/null +++ b/src/main/java/id/iptek/utms/module/controller/ModuleController.java @@ -0,0 +1,43 @@ +package id.iptek.utms.module.controller; + +import id.iptek.utms.api.ApiResponse; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.module.dto.ModuleResponse; +import id.iptek.utms.module.dto.ModuleToggleRequest; +import id.iptek.utms.module.service.ModuleRegistryService; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/modules") +@SecurityRequirement(name = "bearerAuth") +public class ModuleController { + + private final ModuleRegistryService moduleRegistryService; + private final MessageResolver messageResolver; + + public ModuleController(ModuleRegistryService moduleRegistryService, MessageResolver messageResolver) { + this.moduleRegistryService = moduleRegistryService; + this.messageResolver = messageResolver; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse> list() { + return ApiResponse.ok(messageResolver.get("module.list.success"), moduleRegistryService.listModules()); + } + + @PostMapping("/{code}/toggle") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse toggle(@PathVariable String code, + @RequestBody ModuleToggleRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok(messageResolver.get("module.toggle.success"), + moduleRegistryService.setEnabled(code, request.enabled(), servletRequest)); + } +} + diff --git a/src/main/java/id/iptek/utms/module/domain/SystemModule.java b/src/main/java/id/iptek/utms/module/domain/SystemModule.java new file mode 100644 index 0000000..beebe3e --- /dev/null +++ b/src/main/java/id/iptek/utms/module/domain/SystemModule.java @@ -0,0 +1,35 @@ +package id.iptek.utms.module.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sys_system_modules", uniqueConstraints = { + @UniqueConstraint(name = "sys_uk_system_modules_tenant_code", columnNames = {"tenant_id", "code"}) +}) +public class SystemModule extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private boolean enabled; +} + diff --git a/src/main/java/id/iptek/utms/module/dto/ModuleResponse.java b/src/main/java/id/iptek/utms/module/dto/ModuleResponse.java new file mode 100644 index 0000000..f02af6b --- /dev/null +++ b/src/main/java/id/iptek/utms/module/dto/ModuleResponse.java @@ -0,0 +1,9 @@ +package id.iptek.utms.module.dto; + +public record ModuleResponse( + String code, + String name, + boolean enabled +) { +} + diff --git a/src/main/java/id/iptek/utms/module/dto/ModuleToggleRequest.java b/src/main/java/id/iptek/utms/module/dto/ModuleToggleRequest.java new file mode 100644 index 0000000..50520a5 --- /dev/null +++ b/src/main/java/id/iptek/utms/module/dto/ModuleToggleRequest.java @@ -0,0 +1,5 @@ +package id.iptek.utms.module.dto; + +public record ModuleToggleRequest(boolean enabled) { +} + diff --git a/src/main/java/id/iptek/utms/module/repository/SystemModuleRepository.java b/src/main/java/id/iptek/utms/module/repository/SystemModuleRepository.java new file mode 100644 index 0000000..e7e94fc --- /dev/null +++ b/src/main/java/id/iptek/utms/module/repository/SystemModuleRepository.java @@ -0,0 +1,14 @@ +package id.iptek.utms.module.repository; + +import id.iptek.utms.module.domain.SystemModule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface SystemModuleRepository extends JpaRepository { + Optional findByTenantIdAndCode(String tenantId, String code); + List findByTenantId(String tenantId); +} + diff --git a/src/main/java/id/iptek/utms/module/service/Module.java b/src/main/java/id/iptek/utms/module/service/Module.java new file mode 100644 index 0000000..9577aa6 --- /dev/null +++ b/src/main/java/id/iptek/utms/module/service/Module.java @@ -0,0 +1,8 @@ +package id.iptek.utms.module.service; + +public interface Module { + String code(); + void onEnabled(String tenantId); + void onDisabled(String tenantId); +} + diff --git a/src/main/java/id/iptek/utms/module/service/ModuleRegistryService.java b/src/main/java/id/iptek/utms/module/service/ModuleRegistryService.java new file mode 100644 index 0000000..eafea28 --- /dev/null +++ b/src/main/java/id/iptek/utms/module/service/ModuleRegistryService.java @@ -0,0 +1,87 @@ +package id.iptek.utms.module.service; + +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.core.audit.service.AuditTrailService; +import id.iptek.utms.module.domain.SystemModule; +import id.iptek.utms.module.dto.ModuleResponse; +import id.iptek.utms.module.repository.SystemModuleRepository; +import id.iptek.utms.tenant.TenantContext; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ModuleRegistryService { + + private final SystemModuleRepository systemModuleRepository; + private final Map moduleHandlers; + private final AuditTrailService auditTrailService; + + public ModuleRegistryService(SystemModuleRepository systemModuleRepository, + List moduleHandlers, + AuditTrailService auditTrailService) { + this.systemModuleRepository = systemModuleRepository; + this.moduleHandlers = moduleHandlers.stream().collect(Collectors.toMap(Module::code, Function.identity())); + this.auditTrailService = auditTrailService; + } + + public List listModules() { + String tenantId = TenantContext.getRequiredTenantId(); + return systemModuleRepository.findByTenantId(tenantId).stream() + .map(module -> new ModuleResponse(module.getCode(), module.getName(), module.isEnabled())) + .toList(); + } + + @Transactional + public ModuleResponse setEnabled(String code, boolean enabled, HttpServletRequest request) { + String tenantId = TenantContext.getRequiredTenantId(); + SystemModule module = systemModuleRepository.findByTenantIdAndCode(tenantId, code) + .orElseThrow(() -> new AppException("Module not found: " + code)); + String beforeState = auditTrailService.toJson(moduleSnapshot(module)); + + module.setEnabled(enabled); + systemModuleRepository.save(module); + + String afterState = auditTrailService.toJson(moduleSnapshot(module)); + auditTrailService.record( + "MODULE_TOGGLE", + "MODULE", + "SystemModule", + module.getId() != null ? module.getId().toString() : code, + AuditTrailService.SUCCESS, + "Module toggle changed", + beforeState, + afterState, + null, + request + ); + + Module handler = moduleHandlers.get(code); + if (handler != null) { + if (enabled) { + handler.onEnabled(tenantId); + } else { + handler.onDisabled(tenantId); + } + } + + return new ModuleResponse(module.getCode(), module.getName(), module.isEnabled()); + } + + private Map moduleSnapshot(SystemModule module) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", module.getId() != null ? module.getId().toString() : null); + snapshot.put("tenantId", module.getTenantId()); + snapshot.put("code", module.getCode()); + snapshot.put("name", module.getName()); + snapshot.put("enabled", module.isEnabled()); + return snapshot; + } +} + diff --git a/src/main/java/id/iptek/utms/module/service/NotificationModule.java b/src/main/java/id/iptek/utms/module/service/NotificationModule.java new file mode 100644 index 0000000..bf07ce6 --- /dev/null +++ b/src/main/java/id/iptek/utms/module/service/NotificationModule.java @@ -0,0 +1,27 @@ +package id.iptek.utms.module.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class NotificationModule implements Module { + + private static final Logger log = LoggerFactory.getLogger(NotificationModule.class); + + @Override + public String code() { + return "NOTIFICATION"; + } + + @Override + public void onEnabled(String tenantId) { + log.info("Notification module enabled for tenant={}", tenantId); + } + + @Override + public void onDisabled(String tenantId) { + log.info("Notification module disabled for tenant={}", tenantId); + } +} + diff --git a/src/main/java/id/iptek/utms/preference/domain/UserUiPreference.java b/src/main/java/id/iptek/utms/preference/domain/UserUiPreference.java new file mode 100644 index 0000000..94bf3cb --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/domain/UserUiPreference.java @@ -0,0 +1,41 @@ +package id.iptek.utms.preference.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sec_user_ui_preferences", uniqueConstraints = { + @UniqueConstraint(name = "sec_uk_user_ui_preferences", columnNames = {"tenant_id", "user_id", "preference_key"}) +}) +public class UserUiPreference extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "user_id", nullable = false, updatable = false) + private UUID userId; + + @Column(name = "preference_key", nullable = false, length = 255) + private String preferenceKey; + + @Column(name = "value_json", nullable = false, columnDefinition = "text") + private String valueJson; +} + diff --git a/src/main/java/id/iptek/utms/preference/dto/TablePreferenceProfile.java b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceProfile.java new file mode 100644 index 0000000..1d205b5 --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceProfile.java @@ -0,0 +1,10 @@ +package id.iptek.utms.preference.dto; + +import java.util.List; + +public record TablePreferenceProfile( + String preferenceKey, + List visibleColumns +) { +} + diff --git a/src/main/java/id/iptek/utms/preference/dto/TablePreferenceRequest.java b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceRequest.java new file mode 100644 index 0000000..0f9b7fe --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceRequest.java @@ -0,0 +1,13 @@ +package id.iptek.utms.preference.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record TablePreferenceRequest( + @NotBlank String preferenceKey, + @NotEmpty List<@NotBlank String> visibleColumns +) { +} + diff --git a/src/main/java/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.java b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.java new file mode 100644 index 0000000..09207b1 --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.java @@ -0,0 +1,12 @@ +package id.iptek.utms.preference.dto; + +import java.time.Instant; +import java.util.List; + +public record TablePreferenceSavedProfile( + String preferenceKey, + List visibleColumns, + Instant updatedAt +) { +} + diff --git a/src/main/java/id/iptek/utms/preference/dto/UserUiPreferencesResponse.java b/src/main/java/id/iptek/utms/preference/dto/UserUiPreferencesResponse.java new file mode 100644 index 0000000..022d988 --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/dto/UserUiPreferencesResponse.java @@ -0,0 +1,11 @@ +package id.iptek.utms.preference.dto; + +import java.time.Instant; +import java.util.List; + +public record UserUiPreferencesResponse( + List columns, + Instant updatedAt +) { +} + diff --git a/src/main/java/id/iptek/utms/preference/repository/UserUiPreferenceRepository.java b/src/main/java/id/iptek/utms/preference/repository/UserUiPreferenceRepository.java new file mode 100644 index 0000000..4859f3f --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/repository/UserUiPreferenceRepository.java @@ -0,0 +1,19 @@ +package id.iptek.utms.preference.repository; + +import id.iptek.utms.preference.domain.UserUiPreference; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserUiPreferenceRepository extends JpaRepository { + List findByUserId(UUID userId); + + Optional findByUserIdAndPreferenceKey(UUID userId, String preferenceKey); + + void deleteByUserIdAndPreferenceKey(UUID userId, String preferenceKey); + + void deleteByUserId(UUID userId); +} + diff --git a/src/main/java/id/iptek/utms/preference/service/UserPreferenceService.java b/src/main/java/id/iptek/utms/preference/service/UserPreferenceService.java new file mode 100644 index 0000000..5f46705 --- /dev/null +++ b/src/main/java/id/iptek/utms/preference/service/UserPreferenceService.java @@ -0,0 +1,187 @@ +package id.iptek.utms.preference.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import id.iptek.utms.auth.security.UserPrincipal; +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.preference.domain.UserUiPreference; +import id.iptek.utms.preference.dto.TablePreferenceProfile; +import id.iptek.utms.preference.dto.TablePreferenceRequest; +import id.iptek.utms.preference.dto.TablePreferenceSavedProfile; +import id.iptek.utms.preference.dto.UserUiPreferencesResponse; +import id.iptek.utms.preference.repository.UserUiPreferenceRepository; +import id.iptek.utms.tenant.TenantContext; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +public class UserPreferenceService { + + private static final String VALUE_JSON_FIELD = "visibleColumns"; + private static final Pattern PREFERENCE_KEY_PATTERN = + Pattern.compile("^(users|roles|workflow|audit|modules):[A-Za-z0-9_./-]+$"); + private static final Map> DEFAULT_COLUMNS_BY_KEY = Map.of( + "users:workflow-requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "requiredSteps", "currentStep", "createdAt", "updatedAt", "actions"), + "workflow:requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "updatedAt") + ); + + private final UserUiPreferenceRepository repository; + private final ObjectMapper objectMapper; + private final MessageResolver messageResolver; + + public UserPreferenceService(UserUiPreferenceRepository repository, + ObjectMapper objectMapper, + MessageResolver messageResolver) { + this.repository = repository; + this.objectMapper = objectMapper; + this.messageResolver = messageResolver; + } + + public UserUiPreferencesResponse getAll(Authentication authentication) { + UUID userId = getUserId(authentication); + TenantContext.getRequiredTenantId(); + + List preferences = repository.findByUserId(userId); + preferences.sort(Comparator.comparing(UserUiPreference::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder()))); + + List columns = preferences.stream() + .map(this::toProfile) + .toList(); + Instant latestUpdatedAt = preferences.stream() + .map(UserUiPreference::getUpdatedAt) + .filter(Objects::nonNull) + .max(Instant::compareTo) + .orElse(null); + + return new UserUiPreferencesResponse(columns, latestUpdatedAt); + } + + @Transactional + public TablePreferenceSavedProfile upsert(Authentication authentication, TablePreferenceRequest request) { + UUID userId = getUserId(authentication); + String tenantId = TenantContext.getRequiredTenantId(); + String normalizedKey = normalizePreferenceKey(request.preferenceKey()); + List normalizedColumns = normalizeVisibleColumns(request.visibleColumns()); + + UserUiPreference preference = repository.findByUserIdAndPreferenceKey(userId, normalizedKey) + .orElseGet(() -> { + UserUiPreference created = new UserUiPreference(); + created.setUserId(userId); + created.setTenantId(tenantId); + created.setPreferenceKey(normalizedKey); + return created; + }); + preference.setValueJson(serializePreferenceValue(normalizedColumns)); + UserUiPreference saved = repository.save(preference); + return new TablePreferenceSavedProfile( + saved.getPreferenceKey(), + normalizedColumns, + saved.getUpdatedAt() + ); + } + + @Transactional + public TablePreferenceProfile resetTablePreference(Authentication authentication, String preferenceKey) { + UUID userId = getUserId(authentication); + String normalizedKey = normalizePreferenceKey(preferenceKey); + + repository.deleteByUserIdAndPreferenceKey(userId, normalizedKey); + return new TablePreferenceProfile(normalizedKey, getDefaultColumns(normalizedKey)); + } + + @Transactional + public void resetAll(Authentication authentication) { + UUID userId = getUserId(authentication); + repository.deleteByUserId(userId); + } + + private TablePreferenceProfile toProfile(UserUiPreference preference) { + return new TablePreferenceProfile( + preference.getPreferenceKey(), + parseVisibleColumns(preference.getValueJson()) + ); + } + + private UUID getUserId(Authentication authentication) { + Object principal = authentication != null ? authentication.getPrincipal() : null; + if (principal instanceof UserPrincipal userPrincipal) { + return userPrincipal.getId(); + } + throw new AppException(messageResolver.get("auth.invalid.credentials")); + } + + private String normalizePreferenceKey(String preferenceKey) { + if (preferenceKey == null || preferenceKey.isBlank()) { + throw new AppException(messageResolver.get("user.preferences.invalid.key")); + } + String normalized = preferenceKey.trim(); + if (!PREFERENCE_KEY_PATTERN.matcher(normalized).matches()) { + throw new AppException(messageResolver.get("user.preferences.invalid.key")); + } + return normalized; + } + + private List normalizeVisibleColumns(List visibleColumns) { + if (visibleColumns == null || visibleColumns.isEmpty()) { + throw new AppException(messageResolver.get("user.preferences.invalid.columns")); + } + List normalized = visibleColumns.stream() + .map(column -> column == null ? null : column.trim()) + .filter(Objects::nonNull) + .toList(); + if (normalized.isEmpty() || normalized.stream().anyMatch(String::isBlank)) { + throw new AppException(messageResolver.get("user.preferences.invalid.columns")); + } + return List.copyOf(normalized); + } + + private String serializePreferenceValue(List visibleColumns) { + try { + Map> value = new LinkedHashMap<>(); + value.put(VALUE_JSON_FIELD, visibleColumns); + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new AppException(messageResolver.get("user.preferences.serialize.failed")); + } + } + + private List parseVisibleColumns(String valueJson) { + try { + JsonNode root = objectMapper.readTree(valueJson); + JsonNode columns = root.get(VALUE_JSON_FIELD); + if (columns == null || !columns.isArray()) { + throw new AppException(messageResolver.get("user.preferences.invalid.value")); + } + List visibleColumns = new ArrayList<>(); + for (JsonNode value : columns) { + String column = value != null ? value.asText() : null; + if (column == null || column.isBlank()) { + throw new AppException(messageResolver.get("user.preferences.invalid.value")); + } + visibleColumns.add(column); + } + return visibleColumns; + } catch (AppException ex) { + throw ex; + } catch (Exception ex) { + throw new AppException(messageResolver.get("user.preferences.invalid.value")); + } + } + + private List getDefaultColumns(String preferenceKey) { + return DEFAULT_COLUMNS_BY_KEY.getOrDefault(preferenceKey, List.of("id", "createdAt", "updatedAt", "actions")); + } +} + diff --git a/src/main/java/id/iptek/utms/tenant/Tenant.java b/src/main/java/id/iptek/utms/tenant/Tenant.java new file mode 100644 index 0000000..c55d5fb --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/Tenant.java @@ -0,0 +1,33 @@ +package id.iptek.utms.tenant; + +import id.iptek.utms.core.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "sys_tenants") +public class Tenant { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "tenant_id", nullable = false, unique = true) + private String tenantId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/id/iptek/utms/tenant/TenantContext.java b/src/main/java/id/iptek/utms/tenant/TenantContext.java new file mode 100644 index 0000000..327bd1f --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/TenantContext.java @@ -0,0 +1,30 @@ +package id.iptek.utms.tenant; + +public final class TenantContext { + + private static final ThreadLocal TENANT = new ThreadLocal<>(); + + private TenantContext() { + } + + public static void setTenantId(String tenantId) { + TENANT.set(tenantId); + } + + public static String getTenantId() { + return TENANT.get(); + } + + public static String getRequiredTenantId() { + String tenantId = TENANT.get(); + if (tenantId == null || tenantId.isBlank()) { + throw new IllegalStateException("Tenant context is not set"); + } + return tenantId; + } + + public static void clear() { + TENANT.remove(); + } +} + diff --git a/src/main/java/id/iptek/utms/tenant/TenantFilter.java b/src/main/java/id/iptek/utms/tenant/TenantFilter.java new file mode 100644 index 0000000..43062ee --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/TenantFilter.java @@ -0,0 +1,35 @@ +package id.iptek.utms.tenant; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TenantFilter extends OncePerRequestFilter { + + public static final String TENANT_HEADER = "X-Tenant-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String tenantId = request.getHeader(TENANT_HEADER); + if (tenantId != null && !tenantId.isBlank()) { + TenantContext.setTenantId(tenantId); + } + filterChain.doFilter(request, response); + } finally { + TenantContext.clear(); + } + } +} + diff --git a/src/main/java/id/iptek/utms/tenant/TenantHibernateFilter.java b/src/main/java/id/iptek/utms/tenant/TenantHibernateFilter.java new file mode 100644 index 0000000..0896e42 --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/TenantHibernateFilter.java @@ -0,0 +1,44 @@ +package id.iptek.utms.tenant; + +import jakarta.persistence.EntityManager; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.hibernate.Session; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class TenantHibernateFilter extends OncePerRequestFilter { + + private final EntityManager entityManager; + + public TenantHibernateFilter(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String tenantId = TenantContext.getTenantId(); + Session session = entityManager.unwrap(Session.class); + if (tenantId != null && !tenantId.isBlank()) { + session.enableFilter("tenantFilter").setParameter("tenantId", tenantId); + } + try { + filterChain.doFilter(request, response); + } finally { + if (session.getEnabledFilter("tenantFilter") != null) { + session.disableFilter("tenantFilter"); + } + } + } +} + diff --git a/src/main/java/id/iptek/utms/tenant/TenantRepository.java b/src/main/java/id/iptek/utms/tenant/TenantRepository.java new file mode 100644 index 0000000..514ba0d --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/TenantRepository.java @@ -0,0 +1,11 @@ +package id.iptek.utms.tenant; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface TenantRepository extends JpaRepository { + Optional findByTenantIdAndActiveTrue(String tenantId); +} + diff --git a/src/main/java/id/iptek/utms/tenant/TenantService.java b/src/main/java/id/iptek/utms/tenant/TenantService.java new file mode 100644 index 0000000..3e20ca2 --- /dev/null +++ b/src/main/java/id/iptek/utms/tenant/TenantService.java @@ -0,0 +1,22 @@ +package id.iptek.utms.tenant; + +import id.iptek.utms.core.exception.AppException; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class TenantService { + + private final TenantRepository tenantRepository; + + public TenantService(TenantRepository tenantRepository) { + this.tenantRepository = tenantRepository; + } + + @Cacheable(cacheNames = "tenant:active", key = "#tenantId") + public Tenant getActiveTenant(String tenantId) { + return tenantRepository.findByTenantIdAndActiveTrue(tenantId) + .orElseThrow(() -> new AppException("Tenant is invalid or inactive")); + } +} + diff --git a/src/main/java/id/iptek/utms/workflow/controller/ApprovalWorkflowController.java b/src/main/java/id/iptek/utms/workflow/controller/ApprovalWorkflowController.java new file mode 100644 index 0000000..2f41231 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/controller/ApprovalWorkflowController.java @@ -0,0 +1,71 @@ +package id.iptek.utms.workflow.controller; + +import id.iptek.utms.api.ApiResponse; +import id.iptek.utms.core.i18n.MessageResolver; +import id.iptek.utms.workflow.dto.ApprovalActionRequest; +import id.iptek.utms.workflow.dto.ApprovalResponse; +import id.iptek.utms.workflow.dto.CreateApprovalRequest; +import id.iptek.utms.workflow.dto.ApprovalRequestSummary; +import id.iptek.utms.workflow.service.ApprovalWorkflowService; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/workflow") +@SecurityRequirement(name = "bearerAuth") +public class ApprovalWorkflowController { + + private final ApprovalWorkflowService workflowService; + private final MessageResolver messageResolver; + + public ApprovalWorkflowController(ApprovalWorkflowService workflowService, MessageResolver messageResolver) { + this.workflowService = workflowService; + this.messageResolver = messageResolver; + } + + @PostMapping("/request") + @PreAuthorize("hasAuthority('WORKFLOW_CREATE') or hasRole('MAKER')") + public ApiResponse create(@Valid @RequestBody CreateApprovalRequest request, + HttpServletRequest servletRequest) { + return ApiResponse.ok(messageResolver.get("workflow.request.created"), + workflowService.createRequest(request, servletRequest)); + } + + @PostMapping("/{id}/approve") + @PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER')") + public ApiResponse approve(@PathVariable UUID id, + @Valid @RequestBody ApprovalActionRequest request, + Authentication authentication, + HttpServletRequest servletRequest) { + return ApiResponse.ok(messageResolver.get("workflow.request.approved"), + workflowService.approve(id, request, authentication, servletRequest)); + } + + @PostMapping("/{id}/reject") + @PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER')") + public ApiResponse reject(@PathVariable UUID id, + @Valid @RequestBody ApprovalActionRequest request, + Authentication authentication, + HttpServletRequest servletRequest) { + return ApiResponse.ok(messageResolver.get("workflow.request.rejected"), + workflowService.reject(id, request, authentication, servletRequest)); + } + + @GetMapping("/requests") + @PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER') or hasRole('ADMIN')") + public ApiResponse> listRequests(@RequestParam(required = false) String status, + @RequestParam(required = false) String resourceType, + @RequestParam(required = false) String makerUsername, + @RequestParam(defaultValue = "50") int limit) { + return ApiResponse.ok(messageResolver.get("workflow.request.listed"), + workflowService.listRequests(status, resourceType, makerUsername, limit)); + } +} + diff --git a/src/main/java/id/iptek/utms/workflow/domain/ApprovalAction.java b/src/main/java/id/iptek/utms/workflow/domain/ApprovalAction.java new file mode 100644 index 0000000..fb1aef9 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/domain/ApprovalAction.java @@ -0,0 +1,9 @@ +package id.iptek.utms.workflow.domain; + +public enum ApprovalAction { + CREATE, + SUBMIT, + APPROVE, + REJECT +} + diff --git a/src/main/java/id/iptek/utms/workflow/domain/ApprovalHistory.java b/src/main/java/id/iptek/utms/workflow/domain/ApprovalHistory.java new file mode 100644 index 0000000..8287298 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/domain/ApprovalHistory.java @@ -0,0 +1,38 @@ +package id.iptek.utms.workflow.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sys_approval_history") +public class ApprovalHistory extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "request_id", nullable = false) + private ApprovalRequest request; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApprovalAction action; + + @Column(name = "actor_username", nullable = false) + private String actorUsername; + + @Column(columnDefinition = "text") + private String notes; +} + diff --git a/src/main/java/id/iptek/utms/workflow/domain/ApprovalRequest.java b/src/main/java/id/iptek/utms/workflow/domain/ApprovalRequest.java new file mode 100644 index 0000000..3a8fa2f --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/domain/ApprovalRequest.java @@ -0,0 +1,46 @@ +package id.iptek.utms.workflow.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sys_approval_requests") +public class ApprovalRequest extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "resource_type", nullable = false) + private String resourceType; + + @Column(name = "resource_id", nullable = false) + private String resourceId; + + @Column(name = "payload", columnDefinition = "text") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApprovalStatus status = ApprovalStatus.DRAFT; + + @Column(name = "required_steps", nullable = false) + private Integer requiredSteps = 1; + + @Column(name = "current_step", nullable = false) + private Integer currentStep = 0; + + @Column(name = "maker_username", nullable = false) + private String makerUsername; +} + diff --git a/src/main/java/id/iptek/utms/workflow/domain/ApprovalStatus.java b/src/main/java/id/iptek/utms/workflow/domain/ApprovalStatus.java new file mode 100644 index 0000000..9fc8f30 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/domain/ApprovalStatus.java @@ -0,0 +1,9 @@ +package id.iptek.utms.workflow.domain; + +public enum ApprovalStatus { + DRAFT, + PENDING, + APPROVED, + REJECTED +} + diff --git a/src/main/java/id/iptek/utms/workflow/domain/ApprovalStep.java b/src/main/java/id/iptek/utms/workflow/domain/ApprovalStep.java new file mode 100644 index 0000000..23eb7db --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/domain/ApprovalStep.java @@ -0,0 +1,38 @@ +package id.iptek.utms.workflow.domain; + +import id.iptek.utms.core.domain.BaseEntity; +import id.iptek.utms.core.domain.TenantEntityListener; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Filter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@EntityListeners(TenantEntityListener.class) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +@Table(name = "sys_approval_steps") +public class ApprovalStep extends BaseEntity { + + @Id + @GeneratedValue + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "request_id", nullable = false) + private ApprovalRequest request; + + @Column(name = "step_order", nullable = false) + private Integer stepOrder; + + @Column(name = "checker_role", nullable = false) + private String checkerRole; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApprovalStatus status = ApprovalStatus.PENDING; +} + diff --git a/src/main/java/id/iptek/utms/workflow/dto/ApprovalActionRequest.java b/src/main/java/id/iptek/utms/workflow/dto/ApprovalActionRequest.java new file mode 100644 index 0000000..bdf215a --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/dto/ApprovalActionRequest.java @@ -0,0 +1,8 @@ +package id.iptek.utms.workflow.dto; + +public record ApprovalActionRequest( + String notes, + String checkerRole +) { +} + diff --git a/src/main/java/id/iptek/utms/workflow/dto/ApprovalRequestSummary.java b/src/main/java/id/iptek/utms/workflow/dto/ApprovalRequestSummary.java new file mode 100644 index 0000000..7037b11 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/dto/ApprovalRequestSummary.java @@ -0,0 +1,21 @@ +package id.iptek.utms.workflow.dto; + +import id.iptek.utms.workflow.domain.ApprovalStatus; + +import java.time.Instant; +import java.util.UUID; + +public record ApprovalRequestSummary( + UUID id, + String tenantId, + String resourceType, + String resourceId, + String makerUsername, + String payload, + ApprovalStatus status, + int requiredSteps, + int currentStep, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/src/main/java/id/iptek/utms/workflow/dto/ApprovalResponse.java b/src/main/java/id/iptek/utms/workflow/dto/ApprovalResponse.java new file mode 100644 index 0000000..98dd7c1 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/dto/ApprovalResponse.java @@ -0,0 +1,16 @@ +package id.iptek.utms.workflow.dto; + +import id.iptek.utms.workflow.domain.ApprovalStatus; + +import java.util.UUID; + +public record ApprovalResponse( + UUID id, + String resourceType, + String resourceId, + ApprovalStatus status, + int requiredSteps, + int currentStep +) { +} + diff --git a/src/main/java/id/iptek/utms/workflow/dto/CreateApprovalRequest.java b/src/main/java/id/iptek/utms/workflow/dto/CreateApprovalRequest.java new file mode 100644 index 0000000..4cb8823 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/dto/CreateApprovalRequest.java @@ -0,0 +1,13 @@ +package id.iptek.utms.workflow.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record CreateApprovalRequest( + @NotBlank String resourceType, + @NotBlank String resourceId, + String payload, + @Min(1) int requiredSteps +) { +} + diff --git a/src/main/java/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.java b/src/main/java/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.java new file mode 100644 index 0000000..3365992 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.java @@ -0,0 +1,10 @@ +package id.iptek.utms.workflow.repository; + +import id.iptek.utms.workflow.domain.ApprovalHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface ApprovalHistoryRepository extends JpaRepository { +} + diff --git a/src/main/java/id/iptek/utms/workflow/repository/ApprovalRequestRepository.java b/src/main/java/id/iptek/utms/workflow/repository/ApprovalRequestRepository.java new file mode 100644 index 0000000..030fcfd --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/repository/ApprovalRequestRepository.java @@ -0,0 +1,34 @@ +package id.iptek.utms.workflow.repository; + +import id.iptek.utms.workflow.domain.ApprovalRequest; +import id.iptek.utms.workflow.domain.ApprovalStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApprovalRequestRepository extends JpaRepository { + Optional findByIdAndTenantId(UUID id, String tenantId); + List findByTenantIdAndStatus(String tenantId, ApprovalStatus status, Pageable pageable); + List findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable); + + @Query(""" + SELECT r + FROM ApprovalRequest r + WHERE r.tenantId = :tenantId + AND (:status IS NULL OR r.status = :status) + AND (:resourceType IS NULL OR LOWER(r.resourceType) = LOWER(:resourceType)) + AND (:makerUsername IS NULL OR LOWER(r.makerUsername) = LOWER(:makerUsername)) + ORDER BY r.createdAt DESC + """) + List findByTenantIdWithFilters(@Param("tenantId") String tenantId, + @Param("status") ApprovalStatus status, + @Param("resourceType") String resourceType, + @Param("makerUsername") String makerUsername, + Pageable pageable); +} + diff --git a/src/main/java/id/iptek/utms/workflow/repository/ApprovalStepRepository.java b/src/main/java/id/iptek/utms/workflow/repository/ApprovalStepRepository.java new file mode 100644 index 0000000..76ca7ff --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/repository/ApprovalStepRepository.java @@ -0,0 +1,12 @@ +package id.iptek.utms.workflow.repository; + +import id.iptek.utms.workflow.domain.ApprovalStep; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface ApprovalStepRepository extends JpaRepository { + Optional findByRequestIdAndTenantIdAndStepOrder(UUID requestId, String tenantId, Integer stepOrder); +} + diff --git a/src/main/java/id/iptek/utms/workflow/service/ApprovalWorkflowService.java b/src/main/java/id/iptek/utms/workflow/service/ApprovalWorkflowService.java new file mode 100644 index 0000000..9d11258 --- /dev/null +++ b/src/main/java/id/iptek/utms/workflow/service/ApprovalWorkflowService.java @@ -0,0 +1,327 @@ +package id.iptek.utms.workflow.service; + +import id.iptek.utms.core.exception.AppException; +import id.iptek.utms.core.audit.service.AuditTrailService; +import id.iptek.utms.core.security.SecurityUtils; +import id.iptek.utms.messaging.ApprovalCompletedEvent; +import id.iptek.utms.messaging.ApprovalEventProducer; +import id.iptek.utms.tenant.TenantContext; +import id.iptek.utms.workflow.domain.*; +import id.iptek.utms.workflow.dto.ApprovalActionRequest; +import id.iptek.utms.workflow.dto.ApprovalResponse; +import id.iptek.utms.workflow.dto.ApprovalRequestSummary; +import id.iptek.utms.workflow.dto.CreateApprovalRequest; +import id.iptek.utms.workflow.repository.ApprovalHistoryRepository; +import id.iptek.utms.workflow.repository.ApprovalRequestRepository; +import id.iptek.utms.workflow.repository.ApprovalStepRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Locale; +import java.util.UUID; + +@Service +public class ApprovalWorkflowService { + + private final ApprovalRequestRepository requestRepository; + private final ApprovalStepRepository stepRepository; + private final ApprovalHistoryRepository historyRepository; + private final ApprovalEventProducer eventProducer; + private final AuditTrailService auditTrailService; + + public ApprovalWorkflowService(ApprovalRequestRepository requestRepository, + ApprovalStepRepository stepRepository, + ApprovalHistoryRepository historyRepository, + ApprovalEventProducer eventProducer, + AuditTrailService auditTrailService) { + this.requestRepository = requestRepository; + this.stepRepository = stepRepository; + this.historyRepository = historyRepository; + this.eventProducer = eventProducer; + this.auditTrailService = auditTrailService; + } + + @Transactional + public ApprovalResponse createRequest(CreateApprovalRequest dto, HttpServletRequest httpServletRequest) { + return createRequest(dto, null, httpServletRequest); + } + + @Transactional + public ApprovalResponse createRequest(CreateApprovalRequest dto, String checkerRole, HttpServletRequest httpServletRequest) { + return createRequest(dto.resourceType(), dto.resourceId(), dto.payload(), dto.requiredSteps(), checkerRole, httpServletRequest); + } + + @Transactional + public ApprovalResponse createRequest(String resourceType, + String resourceId, + String payload, + int requiredSteps, + String checkerRole, + HttpServletRequest httpServletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + String maker = SecurityUtils.currentUsername(); + if (maker == null) { + throw new AppException("Authenticated maker is required"); + } + + String resolvedCheckerRole = (checkerRole == null || checkerRole.isBlank()) + ? "CHECKER" + : checkerRole; + + ApprovalRequest request = new ApprovalRequest(); + request.setTenantId(tenantId); + request.setResourceType(resourceType); + request.setResourceId(resourceId); + request.setPayload(payload); + request.setRequiredSteps(requiredSteps); + request.setCurrentStep(0); + request.setStatus(ApprovalStatus.PENDING); + request.setMakerUsername(maker); + ApprovalRequest saved = requestRepository.save(request); + + for (int i = 1; i <= requiredSteps; i++) { + ApprovalStep step = new ApprovalStep(); + step.setTenantId(tenantId); + step.setRequest(saved); + step.setStepOrder(i); + step.setCheckerRole(resolvedCheckerRole); + step.setStatus(ApprovalStatus.PENDING); + stepRepository.save(step); + } + + addHistory(saved, ApprovalAction.CREATE, maker, "Request created and submitted"); + auditTrailService.record( + "APPROVAL_REQUEST_CREATE", + "WORKFLOW", + "ApprovalRequest", + saved.getId().toString(), + AuditTrailService.SUCCESS, + "Approval request created", + null, + auditTrailService.toJson(snapshotApprovalRequest(saved)), + null, + httpServletRequest + ); + return toResponse(saved); + } + + @Transactional + public ApprovalResponse approve(UUID id, ApprovalActionRequest dto, Authentication auth, HttpServletRequest httpServletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + String checker = auth.getName(); + + ApprovalRequest request = requestRepository.findByIdAndTenantId(id, tenantId) + .orElseThrow(() -> new AppException("Approval request not found")); + String beforeState = auditTrailService.toJson(snapshotApprovalRequest(request)); + + if (request.getStatus() != ApprovalStatus.PENDING) { + throw new AppException("Only pending request can be approved"); + } + if (request.getMakerUsername().equals(checker)) { + throw new AppException("Maker cannot approve own request"); + } + + int nextStep = request.getCurrentStep() + 1; + ApprovalStep step = stepRepository + .findByRequestIdAndTenantIdAndStepOrder(request.getId(), tenantId, nextStep) + .orElseThrow(() -> new AppException("Approval step not found")); + String checkerRole = resolveCheckerRole(step.getCheckerRole(), dto == null ? null : dto.checkerRole()); + validateCheckerRole(auth, checkerRole); + step.setStatus(ApprovalStatus.APPROVED); + + request.setCurrentStep(nextStep); + if (nextStep >= request.getRequiredSteps()) { + request.setStatus(ApprovalStatus.APPROVED); + eventProducer.publishCompleted(new ApprovalCompletedEvent( + request.getId(), + tenantId, + request.getResourceType(), + request.getResourceId(), + checker + )); + } + + stepRepository.save(step); + ApprovalRequest saved = requestRepository.save(request); + auditTrailService.record( + "APPROVAL_REQUEST_APPROVE", + "WORKFLOW", + "ApprovalRequest", + request.getId().toString(), + AuditTrailService.SUCCESS, + "Approval request approved", + beforeState, + auditTrailService.toJson(snapshotApprovalRequest(saved)), + null, + httpServletRequest + ); + addHistory(saved, ApprovalAction.APPROVE, checker, dto.notes()); + return toResponse(saved); + } + + @Transactional + public ApprovalResponse reject(UUID id, ApprovalActionRequest dto, Authentication auth, HttpServletRequest httpServletRequest) { + String tenantId = TenantContext.getRequiredTenantId(); + String checker = auth.getName(); + + ApprovalRequest request = requestRepository.findByIdAndTenantId(id, tenantId) + .orElseThrow(() -> new AppException("Approval request not found")); + String beforeState = auditTrailService.toJson(snapshotApprovalRequest(request)); + + if (request.getStatus() != ApprovalStatus.PENDING) { + throw new AppException("Only pending request can be rejected"); + } + if (request.getMakerUsername().equals(checker)) { + throw new AppException("Maker cannot reject own request"); + } + + int nextStep = request.getCurrentStep() + 1; + ApprovalStep step = stepRepository + .findByRequestIdAndTenantIdAndStepOrder(request.getId(), tenantId, nextStep) + .orElseThrow(() -> new AppException("Approval step not found")); + String checkerRole = resolveCheckerRole(step.getCheckerRole(), dto == null ? null : dto.checkerRole()); + validateCheckerRole(auth, checkerRole); + + step.setStatus(ApprovalStatus.REJECTED); + stepRepository.save(step); + + request.setStatus(ApprovalStatus.REJECTED); + ApprovalRequest saved = requestRepository.save(request); + auditTrailService.record( + "APPROVAL_REQUEST_REJECT", + "WORKFLOW", + "ApprovalRequest", + request.getId().toString(), + AuditTrailService.SUCCESS, + "Approval request rejected", + beforeState, + auditTrailService.toJson(snapshotApprovalRequest(saved)), + null, + httpServletRequest + ); + addHistory(saved, ApprovalAction.REJECT, checker, dto.notes()); + return toResponse(saved); + } + + @Transactional(readOnly = true) + public List listRequests(String statusText, String resourceType, String makerUsername, int limit) { + String tenantId = TenantContext.getRequiredTenantId(); + int maxLimit = Math.max(1, Math.min(limit, 200)); + Pageable pageable = PageRequest.of(0, maxLimit, Sort.by(Sort.Direction.DESC, "createdAt")); + + ApprovalStatus status = parseStatus(statusText); + List requests = requestRepository.findByTenantIdWithFilters( + tenantId, + status, + normalizeQueryParam(resourceType), + normalizeQueryParam(makerUsername), + pageable + ); + + return requests.stream().map(this::toSummary).toList(); + } + + private String normalizeQueryParam(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private ApprovalStatus parseStatus(String statusText) { + if (statusText == null || statusText.isBlank()) { + return null; + } + try { + return ApprovalStatus.valueOf(statusText.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new AppException("Invalid status value: " + statusText); + } + } + + private void validateCheckerRole(Authentication auth, String expectedRole) { + if (auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch("WORKFLOW_APPROVE"::equals)) { + return; + } + + if (expectedRole == null || expectedRole.isBlank()) { + return; + } + + String normalized = "ROLE_" + expectedRole.toUpperCase(Locale.ROOT); + boolean hasRole = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(role -> role.equals(normalized)); + if (!hasRole) { + throw new AppException("Checker does not have required role: " + expectedRole); + } + } + + private String resolveCheckerRole(String stepRole, String overrideRole) { + if (overrideRole != null && !overrideRole.isBlank()) { + return overrideRole; + } + return stepRole; + } + + private void addHistory(ApprovalRequest request, ApprovalAction action, String actor, String notes) { + ApprovalHistory history = new ApprovalHistory(); + history.setTenantId(request.getTenantId()); + history.setRequest(request); + history.setAction(action); + history.setActorUsername(actor); + history.setNotes(notes); + historyRepository.save(history); + } + + private ApprovalResponse toResponse(ApprovalRequest request) { + return new ApprovalResponse( + request.getId(), + request.getResourceType(), + request.getResourceId(), + request.getStatus(), + request.getRequiredSteps(), + request.getCurrentStep() + ); + } + + private ApprovalRequestSummary toSummary(ApprovalRequest request) { + return new ApprovalRequestSummary( + request.getId(), + request.getTenantId(), + request.getResourceType(), + request.getResourceId(), + request.getMakerUsername(), + request.getPayload(), + request.getStatus(), + request.getRequiredSteps(), + request.getCurrentStep(), + request.getCreatedAt(), + request.getUpdatedAt() + ); + } + + private Map snapshotApprovalRequest(ApprovalRequest request) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", request.getId() != null ? request.getId().toString() : null); + snapshot.put("tenantId", request.getTenantId()); + snapshot.put("resourceType", request.getResourceType()); + snapshot.put("resourceId", request.getResourceId()); + snapshot.put("status", request.getStatus() != null ? request.getStatus().name() : null); + snapshot.put("requiredSteps", request.getRequiredSteps()); + snapshot.put("currentStep", request.getCurrentStep()); + snapshot.put("makerUsername", request.getMakerUsername()); + snapshot.put("payload", request.getPayload()); + return snapshot; + } +} + diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..301c30c --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,48 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: dev + jackson: + time-zone: Asia/Jakarta + datasource: + url: jdbc:postgresql://localhost:5432/utmsng + username: utms + password: utms1234 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + open-in-view: true + data: + redis: + host: localhost + port: 6379 + timeout: 2s + cache: + type: redis + activemq: + broker-url: tcp://localhost:61616 + user: admin + password: admin + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: 5 + failed-attempt-window-seconds: 900 + lockout-duration-seconds: 300 + single-login: + enabled: false + jwt: + secret: change-me-this-is-a-very-long-dev-jwt-secret-key-256-bits-min + seed: + enabled: true + + diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..b16c4f6 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,48 @@ +server: + port: 9191 + +spring: + config: + activate: + on-profile: local + jackson: + time-zone: Asia/Jakarta + datasource: + url: jdbc:postgresql://localhost:5432/utmsng + username: utms + password: utms1234 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + open-in-view: true + data: + redis: + host: localhost + port: 6379 + timeout: 2s + cache: + type: redis + activemq: + broker-url: tcp://localhost:61616 + user: admin + password: admin + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: 5 + failed-attempt-window-seconds: 900 + lockout-duration-seconds: 300 + single-login: + enabled: false + jwt: + secret: local-dev-fallback-jwt-secret-key-for-local-dev-environment-256-bits-min + seed: + enabled: true + + diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml new file mode 100644 index 0000000..4b94885 --- /dev/null +++ b/src/main/resources/application-prd.yml @@ -0,0 +1,51 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: prd + jackson: + time-zone: Asia/Jakarta + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: false + jdbc: + time_zone: UTC + open-in-view: false + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:2s} + cache: + type: redis + activemq: + broker-url: ${ACTIVEMQ_BROKER_URL} + user: ${ACTIVEMQ_USER} + password: ${ACTIVEMQ_PASSWORD} + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: ${MAX_LOGIN_ATTEMPTS:5} + failed-attempt-window-seconds: ${LOGIN_FAILED_WINDOW_SECONDS:900} + lockout-duration-seconds: ${LOGIN_LOCKOUT_SECONDS:300} + single-login: + enabled: ${SINGLE_LOGIN_ENABLED:false} + jwt: + secret: ${JWT_SECRET} + seed: + enabled: false + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c66d215 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,55 @@ +server: + port: 8080 + +spring: + application: + name: utms-ng-be + profiles: + active: dev + jackson: + time-zone: Asia/Jakarta + messages: + basename: i18n/messages + default-locale: en_US + +app: + security: + single-login: + enabled: false + jwt: + access-token-minutes: 15 + refresh-token-days: 7 + seed: + enabled: false + ldap: + enabled: false + url: ldap://localhost:389 + base: dc=example,dc=org + manager-dn: "" + manager-password: "" + user-search-base: ou=people + user-search-filter: (uid={0}) + group-search-base: ou=groups + group-search-filter: (uniqueMember={0}) + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + persist-authorization: true + +management: + endpoints: + web: + exposure: + include: health,info + +logging: + level: + org.springframework.security: INFO + id.iptek.utms: INFO + +spring.mvc: + locale: en_US + diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..fa29dd9 --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -0,0 +1,172 @@ +-- Optional reference schema for PostgreSQL (JPA ddl-auto=update is enabled by default) + +create table if not exists sys_tenants ( + id uuid primary key, + tenant_id varchar(100) not null unique, + name varchar(255) not null, + active boolean not null, + created_at timestamp with time zone not null +); + +create table if not exists sec_permissions ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_permissions_tenant_code unique (tenant_id, code) +); + +create table if not exists sec_roles ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_roles_tenant_code unique (tenant_id, code) +); + +create table if not exists sec_users ( + id uuid primary key, + tenant_id varchar(100) not null, + username varchar(100) not null, + password varchar(255), + auth_source varchar(20) not null default 'LOCAL', + ldap_dn varchar(512), + enabled boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_users_tenant_username unique (tenant_id, username) +); + +create table if not exists sec_user_roles ( + user_id uuid not null references sec_users(id), + role_id uuid not null references sec_roles(id), + primary key (user_id, role_id) +); + +create table if not exists sec_role_permissions ( + role_id uuid not null references sec_roles(id), + permission_id uuid not null references sec_permissions(id), + primary key (role_id, permission_id) +); + +create table if not exists sec_user_ui_preferences ( + id uuid primary key, + tenant_id varchar(100) not null, + user_id uuid not null references sec_users(id), + preference_key varchar(255) not null, + value_json text not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_user_ui_preferences unique (tenant_id, user_id, preference_key) +); +create index if not exists sec_idx_user_ui_preferences_tenant_user_updated on sec_user_ui_preferences (tenant_id, user_id, updated_at); +create index if not exists sec_idx_user_ui_preferences_tenant_user on sec_user_ui_preferences (tenant_id, user_id); +create index if not exists sec_idx_user_ui_preferences_user on sec_user_ui_preferences (user_id); + +create table if not exists sec_refresh_tokens ( + id uuid primary key, + tenant_id varchar(100) not null, + user_id uuid not null references sec_users(id), + token varchar(512) not null unique, + expires_at timestamp with time zone not null, + revoked boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_system_modules ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + enabled boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sys_uk_system_modules_tenant_code unique (tenant_id, code) +); + +create table if not exists sys_approval_requests ( + id uuid primary key, + tenant_id varchar(100) not null, + resource_type varchar(255) not null, + resource_id varchar(255) not null, + payload text, + status varchar(50) not null, + required_steps integer not null, + current_step integer not null, + maker_username varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_approval_steps ( + id uuid primary key, + tenant_id varchar(100) not null, + request_id uuid not null references sys_approval_requests(id), + step_order integer not null, + checker_role varchar(255) not null, + status varchar(50) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_approval_history ( + id uuid primary key, + tenant_id varchar(100) not null, + request_id uuid not null references sys_approval_requests(id), + action varchar(50) not null, + actor_username varchar(255) not null, + notes text, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_audit_trails ( + id uuid primary key, + tenant_id varchar(100) not null, + correlation_id varchar(100), + actor varchar(255) not null, + action varchar(100) not null, + domain varchar(100), + resource_type varchar(100), + resource_id varchar(255), + outcome varchar(20) not null, + http_method varchar(20), + request_path varchar(500), + client_ip varchar(80), + error_message varchar(1000), + details text, + before_state text, + after_state text, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create index if not exists sys_idx_audit_tenant_created_on on sys_audit_trails (tenant_id, created_at); +create index if not exists sys_idx_audit_correlation on sys_audit_trails (correlation_id); +create index if not exists sys_idx_audit_actor on sys_audit_trails (actor); +create index if not exists sys_idx_audit_action on sys_audit_trails (action); diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..b75508a --- /dev/null +++ b/src/main/resources/i18n/messages.properties @@ -0,0 +1,33 @@ +auth.login.success=Login successful +auth.refresh.success=Token refreshed successfully +auth.logout.success=Logout successful +user.me.success=Current user fetched successfully +workflow.request.created=Approval request created +workflow.request.approved=Approval request approved +workflow.request.rejected=Approval request rejected +workflow.request.listed=Workflow requests fetched +module.list.success=Modules fetched +module.toggle.success=Module updated +audit.list.success=Audit trail fetched +error.validation=Validation failed +error.forbidden=Access denied +error.internal=Internal server error +user.management.request.created=User management request created +role.management.request.created=Role management request created +auth.invalid.credentials=Invalid username or password +auth.login.locked=Account locked. Please try again in {0} seconds +auth.user.notfound=User not found +auth.user.notfound.for.ldap=LDAP user authenticated but not provisioned in this tenant +auth.refresh.notfound=Refresh token not found +auth.refresh.invalid=Refresh token expired or revoked +auth.single.login.invalid_session=Session is no longer active. Please log in again. +tenant.header.required=X-Tenant-Id header is required +tenant.header.mismatch=X-Tenant-Id header does not match authenticated tenant context +user.preferences.get.success=Preferences retrieved +user.preferences.upsert.success=Table preference saved +user.preferences.reset.table.success=Table preference reset +user.preferences.reset.all.success=All UI preferences reset +user.preferences.invalid.key=Invalid preference key +user.preferences.invalid.columns=Invalid visible columns +user.preferences.serialize.failed=Unable to save preference +user.preferences.invalid.value=Stored preference value is invalid diff --git a/src/main/resources/i18n/messages_id.properties b/src/main/resources/i18n/messages_id.properties new file mode 100644 index 0000000..90338aa --- /dev/null +++ b/src/main/resources/i18n/messages_id.properties @@ -0,0 +1,33 @@ +auth.login.success=Login berhasil +auth.refresh.success=Token berhasil diperbarui +auth.logout.success=Logout berhasil +user.me.success=Data pengguna berhasil diambil +workflow.request.created=Permintaan persetujuan berhasil dibuat +workflow.request.approved=Permintaan persetujuan disetujui +workflow.request.rejected=Permintaan persetujuan ditolak +workflow.request.listed=Permintaan persetujuan diambil +module.list.success=Daftar modul berhasil diambil +module.toggle.success=Status modul berhasil diperbarui +audit.list.success=Riwayat audit berhasil diambil +error.validation=Validasi gagal +error.forbidden=Akses ditolak +error.internal=Terjadi kesalahan internal +user.management.request.created=Permintaan manajemen pengguna telah dibuat +role.management.request.created=Permintaan manajemen peran telah dibuat +auth.invalid.credentials=Username atau password tidak valid +auth.login.locked=Akun terkunci. Silakan coba lagi dalam {0} detik +auth.user.notfound=Pengguna tidak ditemukan +auth.user.notfound.for.ldap=Pengguna LDAP berhasil diautentikasi tetapi belum diprovisioning di tenant ini +auth.refresh.notfound=Token refresh tidak ditemukan +auth.refresh.invalid=Token refresh kedaluwarsa atau dibatalkan +auth.single.login.invalid_session=Session tidak lagi aktif. Silakan masuk kembali. +tenant.header.required=Header X-Tenant-Id wajib diisi +tenant.header.mismatch=Header X-Tenant-Id tidak sesuai dengan tenant yang terautentikasi +user.preferences.get.success=Preferensi berhasil diambil +user.preferences.upsert.success=Preferensi tabel berhasil disimpan +user.preferences.reset.table.success=Preferensi tabel berhasil diatur ulang +user.preferences.reset.all.success=Semua preferensi UI berhasil dihapus +user.preferences.invalid.key=Kunci preferensi tidak valid +user.preferences.invalid.columns=Kolom yang terlihat tidak valid +user.preferences.serialize.failed=Tidak dapat menyimpan preferensi +user.preferences.invalid.value=Nilai preferensi tersimpan tidak valid diff --git a/target/classes/application-dev.yml b/target/classes/application-dev.yml new file mode 100644 index 0000000..301c30c --- /dev/null +++ b/target/classes/application-dev.yml @@ -0,0 +1,48 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: dev + jackson: + time-zone: Asia/Jakarta + datasource: + url: jdbc:postgresql://localhost:5432/utmsng + username: utms + password: utms1234 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + open-in-view: true + data: + redis: + host: localhost + port: 6379 + timeout: 2s + cache: + type: redis + activemq: + broker-url: tcp://localhost:61616 + user: admin + password: admin + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: 5 + failed-attempt-window-seconds: 900 + lockout-duration-seconds: 300 + single-login: + enabled: false + jwt: + secret: change-me-this-is-a-very-long-dev-jwt-secret-key-256-bits-min + seed: + enabled: true + + diff --git a/target/classes/application-local.yml b/target/classes/application-local.yml new file mode 100644 index 0000000..b16c4f6 --- /dev/null +++ b/target/classes/application-local.yml @@ -0,0 +1,48 @@ +server: + port: 9191 + +spring: + config: + activate: + on-profile: local + jackson: + time-zone: Asia/Jakarta + datasource: + url: jdbc:postgresql://localhost:5432/utmsng + username: utms + password: utms1234 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + open-in-view: true + data: + redis: + host: localhost + port: 6379 + timeout: 2s + cache: + type: redis + activemq: + broker-url: tcp://localhost:61616 + user: admin + password: admin + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: 5 + failed-attempt-window-seconds: 900 + lockout-duration-seconds: 300 + single-login: + enabled: false + jwt: + secret: local-dev-fallback-jwt-secret-key-for-local-dev-environment-256-bits-min + seed: + enabled: true + + diff --git a/target/classes/application-prd.yml b/target/classes/application-prd.yml new file mode 100644 index 0000000..4b94885 --- /dev/null +++ b/target/classes/application-prd.yml @@ -0,0 +1,51 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: prd + jackson: + time-zone: Asia/Jakarta + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: false + jdbc: + time_zone: UTC + open-in-view: false + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:2s} + cache: + type: redis + activemq: + broker-url: ${ACTIVEMQ_BROKER_URL} + user: ${ACTIVEMQ_USER} + password: ${ACTIVEMQ_PASSWORD} + jms: + listener: + acknowledge-mode: auto +app: + security: + login: + max-failed-attempts: ${MAX_LOGIN_ATTEMPTS:5} + failed-attempt-window-seconds: ${LOGIN_FAILED_WINDOW_SECONDS:900} + lockout-duration-seconds: ${LOGIN_LOCKOUT_SECONDS:300} + single-login: + enabled: ${SINGLE_LOGIN_ENABLED:false} + jwt: + secret: ${JWT_SECRET} + seed: + enabled: false + + diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 0000000..c66d215 --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1,55 @@ +server: + port: 8080 + +spring: + application: + name: utms-ng-be + profiles: + active: dev + jackson: + time-zone: Asia/Jakarta + messages: + basename: i18n/messages + default-locale: en_US + +app: + security: + single-login: + enabled: false + jwt: + access-token-minutes: 15 + refresh-token-days: 7 + seed: + enabled: false + ldap: + enabled: false + url: ldap://localhost:389 + base: dc=example,dc=org + manager-dn: "" + manager-password: "" + user-search-base: ou=people + user-search-filter: (uid={0}) + group-search-base: ou=groups + group-search-filter: (uniqueMember={0}) + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + persist-authorization: true + +management: + endpoints: + web: + exposure: + include: health,info + +logging: + level: + org.springframework.security: INFO + id.iptek.utms: INFO + +spring.mvc: + locale: en_US + diff --git a/target/classes/db/schema.sql b/target/classes/db/schema.sql new file mode 100644 index 0000000..fa29dd9 --- /dev/null +++ b/target/classes/db/schema.sql @@ -0,0 +1,172 @@ +-- Optional reference schema for PostgreSQL (JPA ddl-auto=update is enabled by default) + +create table if not exists sys_tenants ( + id uuid primary key, + tenant_id varchar(100) not null unique, + name varchar(255) not null, + active boolean not null, + created_at timestamp with time zone not null +); + +create table if not exists sec_permissions ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_permissions_tenant_code unique (tenant_id, code) +); + +create table if not exists sec_roles ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_roles_tenant_code unique (tenant_id, code) +); + +create table if not exists sec_users ( + id uuid primary key, + tenant_id varchar(100) not null, + username varchar(100) not null, + password varchar(255), + auth_source varchar(20) not null default 'LOCAL', + ldap_dn varchar(512), + enabled boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_users_tenant_username unique (tenant_id, username) +); + +create table if not exists sec_user_roles ( + user_id uuid not null references sec_users(id), + role_id uuid not null references sec_roles(id), + primary key (user_id, role_id) +); + +create table if not exists sec_role_permissions ( + role_id uuid not null references sec_roles(id), + permission_id uuid not null references sec_permissions(id), + primary key (role_id, permission_id) +); + +create table if not exists sec_user_ui_preferences ( + id uuid primary key, + tenant_id varchar(100) not null, + user_id uuid not null references sec_users(id), + preference_key varchar(255) not null, + value_json text not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sec_uk_user_ui_preferences unique (tenant_id, user_id, preference_key) +); +create index if not exists sec_idx_user_ui_preferences_tenant_user_updated on sec_user_ui_preferences (tenant_id, user_id, updated_at); +create index if not exists sec_idx_user_ui_preferences_tenant_user on sec_user_ui_preferences (tenant_id, user_id); +create index if not exists sec_idx_user_ui_preferences_user on sec_user_ui_preferences (user_id); + +create table if not exists sec_refresh_tokens ( + id uuid primary key, + tenant_id varchar(100) not null, + user_id uuid not null references sec_users(id), + token varchar(512) not null unique, + expires_at timestamp with time zone not null, + revoked boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_system_modules ( + id uuid primary key, + tenant_id varchar(100) not null, + code varchar(100) not null, + name varchar(255) not null, + enabled boolean not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255), + constraint sys_uk_system_modules_tenant_code unique (tenant_id, code) +); + +create table if not exists sys_approval_requests ( + id uuid primary key, + tenant_id varchar(100) not null, + resource_type varchar(255) not null, + resource_id varchar(255) not null, + payload text, + status varchar(50) not null, + required_steps integer not null, + current_step integer not null, + maker_username varchar(255) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_approval_steps ( + id uuid primary key, + tenant_id varchar(100) not null, + request_id uuid not null references sys_approval_requests(id), + step_order integer not null, + checker_role varchar(255) not null, + status varchar(50) not null, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_approval_history ( + id uuid primary key, + tenant_id varchar(100) not null, + request_id uuid not null references sys_approval_requests(id), + action varchar(50) not null, + actor_username varchar(255) not null, + notes text, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create table if not exists sys_audit_trails ( + id uuid primary key, + tenant_id varchar(100) not null, + correlation_id varchar(100), + actor varchar(255) not null, + action varchar(100) not null, + domain varchar(100), + resource_type varchar(100), + resource_id varchar(255), + outcome varchar(20) not null, + http_method varchar(20), + request_path varchar(500), + client_ip varchar(80), + error_message varchar(1000), + details text, + before_state text, + after_state text, + created_at timestamp with time zone, + updated_at timestamp with time zone, + created_by varchar(255), + updated_by varchar(255) +); + +create index if not exists sys_idx_audit_tenant_created_on on sys_audit_trails (tenant_id, created_at); +create index if not exists sys_idx_audit_correlation on sys_audit_trails (correlation_id); +create index if not exists sys_idx_audit_actor on sys_audit_trails (actor); +create index if not exists sys_idx_audit_action on sys_audit_trails (action); diff --git a/target/classes/i18n/messages.properties b/target/classes/i18n/messages.properties new file mode 100644 index 0000000..b75508a --- /dev/null +++ b/target/classes/i18n/messages.properties @@ -0,0 +1,33 @@ +auth.login.success=Login successful +auth.refresh.success=Token refreshed successfully +auth.logout.success=Logout successful +user.me.success=Current user fetched successfully +workflow.request.created=Approval request created +workflow.request.approved=Approval request approved +workflow.request.rejected=Approval request rejected +workflow.request.listed=Workflow requests fetched +module.list.success=Modules fetched +module.toggle.success=Module updated +audit.list.success=Audit trail fetched +error.validation=Validation failed +error.forbidden=Access denied +error.internal=Internal server error +user.management.request.created=User management request created +role.management.request.created=Role management request created +auth.invalid.credentials=Invalid username or password +auth.login.locked=Account locked. Please try again in {0} seconds +auth.user.notfound=User not found +auth.user.notfound.for.ldap=LDAP user authenticated but not provisioned in this tenant +auth.refresh.notfound=Refresh token not found +auth.refresh.invalid=Refresh token expired or revoked +auth.single.login.invalid_session=Session is no longer active. Please log in again. +tenant.header.required=X-Tenant-Id header is required +tenant.header.mismatch=X-Tenant-Id header does not match authenticated tenant context +user.preferences.get.success=Preferences retrieved +user.preferences.upsert.success=Table preference saved +user.preferences.reset.table.success=Table preference reset +user.preferences.reset.all.success=All UI preferences reset +user.preferences.invalid.key=Invalid preference key +user.preferences.invalid.columns=Invalid visible columns +user.preferences.serialize.failed=Unable to save preference +user.preferences.invalid.value=Stored preference value is invalid diff --git a/target/classes/i18n/messages_id.properties b/target/classes/i18n/messages_id.properties new file mode 100644 index 0000000..90338aa --- /dev/null +++ b/target/classes/i18n/messages_id.properties @@ -0,0 +1,33 @@ +auth.login.success=Login berhasil +auth.refresh.success=Token berhasil diperbarui +auth.logout.success=Logout berhasil +user.me.success=Data pengguna berhasil diambil +workflow.request.created=Permintaan persetujuan berhasil dibuat +workflow.request.approved=Permintaan persetujuan disetujui +workflow.request.rejected=Permintaan persetujuan ditolak +workflow.request.listed=Permintaan persetujuan diambil +module.list.success=Daftar modul berhasil diambil +module.toggle.success=Status modul berhasil diperbarui +audit.list.success=Riwayat audit berhasil diambil +error.validation=Validasi gagal +error.forbidden=Akses ditolak +error.internal=Terjadi kesalahan internal +user.management.request.created=Permintaan manajemen pengguna telah dibuat +role.management.request.created=Permintaan manajemen peran telah dibuat +auth.invalid.credentials=Username atau password tidak valid +auth.login.locked=Akun terkunci. Silakan coba lagi dalam {0} detik +auth.user.notfound=Pengguna tidak ditemukan +auth.user.notfound.for.ldap=Pengguna LDAP berhasil diautentikasi tetapi belum diprovisioning di tenant ini +auth.refresh.notfound=Token refresh tidak ditemukan +auth.refresh.invalid=Token refresh kedaluwarsa atau dibatalkan +auth.single.login.invalid_session=Session tidak lagi aktif. Silakan masuk kembali. +tenant.header.required=Header X-Tenant-Id wajib diisi +tenant.header.mismatch=Header X-Tenant-Id tidak sesuai dengan tenant yang terautentikasi +user.preferences.get.success=Preferensi berhasil diambil +user.preferences.upsert.success=Preferensi tabel berhasil disimpan +user.preferences.reset.table.success=Preferensi tabel berhasil diatur ulang +user.preferences.reset.all.success=Semua preferensi UI berhasil dihapus +user.preferences.invalid.key=Kunci preferensi tidak valid +user.preferences.invalid.columns=Kolom yang terlihat tidak valid +user.preferences.serialize.failed=Tidak dapat menyimpan preferensi +user.preferences.invalid.value=Nilai preferensi tersimpan tidak valid diff --git a/target/classes/id/iptek/utms/UtmsNgBeApplication.class b/target/classes/id/iptek/utms/UtmsNgBeApplication.class new file mode 100644 index 0000000..c801d28 Binary files /dev/null and b/target/classes/id/iptek/utms/UtmsNgBeApplication.class differ diff --git a/target/classes/id/iptek/utms/api/ApiResponse.class b/target/classes/id/iptek/utms/api/ApiResponse.class new file mode 100644 index 0000000..a98bfc8 Binary files /dev/null and b/target/classes/id/iptek/utms/api/ApiResponse.class differ diff --git a/target/classes/id/iptek/utms/api/AuditController.class b/target/classes/id/iptek/utms/api/AuditController.class new file mode 100644 index 0000000..8c9f813 Binary files /dev/null and b/target/classes/id/iptek/utms/api/AuditController.class differ diff --git a/target/classes/id/iptek/utms/api/RoleController.class b/target/classes/id/iptek/utms/api/RoleController.class new file mode 100644 index 0000000..3b55396 Binary files /dev/null and b/target/classes/id/iptek/utms/api/RoleController.class differ diff --git a/target/classes/id/iptek/utms/api/TenantController.class b/target/classes/id/iptek/utms/api/TenantController.class new file mode 100644 index 0000000..226c763 Binary files /dev/null and b/target/classes/id/iptek/utms/api/TenantController.class differ diff --git a/target/classes/id/iptek/utms/api/UserController.class b/target/classes/id/iptek/utms/api/UserController.class new file mode 100644 index 0000000..5037083 Binary files /dev/null and b/target/classes/id/iptek/utms/api/UserController.class differ diff --git a/target/classes/id/iptek/utms/auth/config/JwtProperties.class b/target/classes/id/iptek/utms/auth/config/JwtProperties.class new file mode 100644 index 0000000..0d662ec Binary files /dev/null and b/target/classes/id/iptek/utms/auth/config/JwtProperties.class differ diff --git a/target/classes/id/iptek/utms/auth/config/LdapAuthConfig.class b/target/classes/id/iptek/utms/auth/config/LdapAuthConfig.class new file mode 100644 index 0000000..483cdcf Binary files /dev/null and b/target/classes/id/iptek/utms/auth/config/LdapAuthConfig.class differ diff --git a/target/classes/id/iptek/utms/auth/config/LdapProperties.class b/target/classes/id/iptek/utms/auth/config/LdapProperties.class new file mode 100644 index 0000000..9e318b8 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/config/LdapProperties.class differ diff --git a/target/classes/id/iptek/utms/auth/config/SecurityConfig.class b/target/classes/id/iptek/utms/auth/config/SecurityConfig.class new file mode 100644 index 0000000..6411300 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/config/SecurityConfig.class differ diff --git a/target/classes/id/iptek/utms/auth/controller/AuthController.class b/target/classes/id/iptek/utms/auth/controller/AuthController.class new file mode 100644 index 0000000..c8bcdc2 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/controller/AuthController.class differ diff --git a/target/classes/id/iptek/utms/auth/domain/AuthenticationSource.class b/target/classes/id/iptek/utms/auth/domain/AuthenticationSource.class new file mode 100644 index 0000000..a35a4ed Binary files /dev/null and b/target/classes/id/iptek/utms/auth/domain/AuthenticationSource.class differ diff --git a/target/classes/id/iptek/utms/auth/domain/Permission.class b/target/classes/id/iptek/utms/auth/domain/Permission.class new file mode 100644 index 0000000..d998022 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/domain/Permission.class differ diff --git a/target/classes/id/iptek/utms/auth/domain/RefreshToken.class b/target/classes/id/iptek/utms/auth/domain/RefreshToken.class new file mode 100644 index 0000000..96fbd44 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/domain/RefreshToken.class differ diff --git a/target/classes/id/iptek/utms/auth/domain/Role.class b/target/classes/id/iptek/utms/auth/domain/Role.class new file mode 100644 index 0000000..4fecb42 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/domain/Role.class differ diff --git a/target/classes/id/iptek/utms/auth/domain/User.class b/target/classes/id/iptek/utms/auth/domain/User.class new file mode 100644 index 0000000..bcf9e66 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/domain/User.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/AuthTokenResponse.class b/target/classes/id/iptek/utms/auth/dto/AuthTokenResponse.class new file mode 100644 index 0000000..16405b8 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/AuthTokenResponse.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/CreateRoleManagementRequest.class b/target/classes/id/iptek/utms/auth/dto/CreateRoleManagementRequest.class new file mode 100644 index 0000000..985c0aa Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/CreateRoleManagementRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/CreateUserManagementRequest.class b/target/classes/id/iptek/utms/auth/dto/CreateUserManagementRequest.class new file mode 100644 index 0000000..47a890e Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/CreateUserManagementRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/CurrentUserResponse.class b/target/classes/id/iptek/utms/auth/dto/CurrentUserResponse.class new file mode 100644 index 0000000..c096ce8 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/CurrentUserResponse.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/LoginRequest.class b/target/classes/id/iptek/utms/auth/dto/LoginRequest.class new file mode 100644 index 0000000..d4b2f7d Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/LoginRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/RefreshRequest.class b/target/classes/id/iptek/utms/auth/dto/RefreshRequest.class new file mode 100644 index 0000000..ed5e8e2 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/RefreshRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.class b/target/classes/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.class new file mode 100644 index 0000000..49b633b Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/UpdateRolePermissionsRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/dto/UpdateUserRolesRequest.class b/target/classes/id/iptek/utms/auth/dto/UpdateUserRolesRequest.class new file mode 100644 index 0000000..6aebc7c Binary files /dev/null and b/target/classes/id/iptek/utms/auth/dto/UpdateUserRolesRequest.class differ diff --git a/target/classes/id/iptek/utms/auth/repository/PermissionRepository.class b/target/classes/id/iptek/utms/auth/repository/PermissionRepository.class new file mode 100644 index 0000000..02b511e Binary files /dev/null and b/target/classes/id/iptek/utms/auth/repository/PermissionRepository.class differ diff --git a/target/classes/id/iptek/utms/auth/repository/RefreshTokenRepository.class b/target/classes/id/iptek/utms/auth/repository/RefreshTokenRepository.class new file mode 100644 index 0000000..00c2307 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/repository/RefreshTokenRepository.class differ diff --git a/target/classes/id/iptek/utms/auth/repository/RoleRepository.class b/target/classes/id/iptek/utms/auth/repository/RoleRepository.class new file mode 100644 index 0000000..c54b24e Binary files /dev/null and b/target/classes/id/iptek/utms/auth/repository/RoleRepository.class differ diff --git a/target/classes/id/iptek/utms/auth/repository/UserRepository.class b/target/classes/id/iptek/utms/auth/repository/UserRepository.class new file mode 100644 index 0000000..fc75d9e Binary files /dev/null and b/target/classes/id/iptek/utms/auth/repository/UserRepository.class differ diff --git a/target/classes/id/iptek/utms/auth/security/JwtAuthenticationFilter.class b/target/classes/id/iptek/utms/auth/security/JwtAuthenticationFilter.class new file mode 100644 index 0000000..a49cc8b Binary files /dev/null and b/target/classes/id/iptek/utms/auth/security/JwtAuthenticationFilter.class differ diff --git a/target/classes/id/iptek/utms/auth/security/JwtService.class b/target/classes/id/iptek/utms/auth/security/JwtService.class new file mode 100644 index 0000000..e75d6f7 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/security/JwtService.class differ diff --git a/target/classes/id/iptek/utms/auth/security/TenantAwareUserDetailsService.class b/target/classes/id/iptek/utms/auth/security/TenantAwareUserDetailsService.class new file mode 100644 index 0000000..2f56bbc Binary files /dev/null and b/target/classes/id/iptek/utms/auth/security/TenantAwareUserDetailsService.class differ diff --git a/target/classes/id/iptek/utms/auth/security/UserPrincipal.class b/target/classes/id/iptek/utms/auth/security/UserPrincipal.class new file mode 100644 index 0000000..29b331a Binary files /dev/null and b/target/classes/id/iptek/utms/auth/security/UserPrincipal.class differ diff --git a/target/classes/id/iptek/utms/auth/service/AuthService.class b/target/classes/id/iptek/utms/auth/service/AuthService.class new file mode 100644 index 0000000..7c4c1a1 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/AuthService.class differ diff --git a/target/classes/id/iptek/utms/auth/service/LoginThrottleService.class b/target/classes/id/iptek/utms/auth/service/LoginThrottleService.class new file mode 100644 index 0000000..43b8f22 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/LoginThrottleService.class differ diff --git a/target/classes/id/iptek/utms/auth/service/SingleLoginSessionService.class b/target/classes/id/iptek/utms/auth/service/SingleLoginSessionService.class new file mode 100644 index 0000000..5b8069b Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/SingleLoginSessionService.class differ diff --git a/target/classes/id/iptek/utms/auth/service/TokenBlacklistService.class b/target/classes/id/iptek/utms/auth/service/TokenBlacklistService.class new file mode 100644 index 0000000..1e34041 Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/TokenBlacklistService.class differ diff --git a/target/classes/id/iptek/utms/auth/service/UserRoleManagementService$1.class b/target/classes/id/iptek/utms/auth/service/UserRoleManagementService$1.class new file mode 100644 index 0000000..18a44fe Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/UserRoleManagementService$1.class differ diff --git a/target/classes/id/iptek/utms/auth/service/UserRoleManagementService.class b/target/classes/id/iptek/utms/auth/service/UserRoleManagementService.class new file mode 100644 index 0000000..3aae2ee Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/UserRoleManagementService.class differ diff --git a/target/classes/id/iptek/utms/auth/service/UserService.class b/target/classes/id/iptek/utms/auth/service/UserService.class new file mode 100644 index 0000000..df33e9d Binary files /dev/null and b/target/classes/id/iptek/utms/auth/service/UserService.class differ diff --git a/target/classes/id/iptek/utms/core/audit/domain/AuditTrail.class b/target/classes/id/iptek/utms/core/audit/domain/AuditTrail.class new file mode 100644 index 0000000..a2d5eec Binary files /dev/null and b/target/classes/id/iptek/utms/core/audit/domain/AuditTrail.class differ diff --git a/target/classes/id/iptek/utms/core/audit/dto/AuditTrailResponse.class b/target/classes/id/iptek/utms/core/audit/dto/AuditTrailResponse.class new file mode 100644 index 0000000..ef92772 Binary files /dev/null and b/target/classes/id/iptek/utms/core/audit/dto/AuditTrailResponse.class differ diff --git a/target/classes/id/iptek/utms/core/audit/repository/AuditTrailRepository.class b/target/classes/id/iptek/utms/core/audit/repository/AuditTrailRepository.class new file mode 100644 index 0000000..8cae04c Binary files /dev/null and b/target/classes/id/iptek/utms/core/audit/repository/AuditTrailRepository.class differ diff --git a/target/classes/id/iptek/utms/core/audit/service/AuditTrailService.class b/target/classes/id/iptek/utms/core/audit/service/AuditTrailService.class new file mode 100644 index 0000000..afa3d76 Binary files /dev/null and b/target/classes/id/iptek/utms/core/audit/service/AuditTrailService.class differ diff --git a/target/classes/id/iptek/utms/core/config/ActiveMqConfig.class b/target/classes/id/iptek/utms/core/config/ActiveMqConfig.class new file mode 100644 index 0000000..d1fd843 Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/ActiveMqConfig.class differ diff --git a/target/classes/id/iptek/utms/core/config/AuditLoggingAspect.class b/target/classes/id/iptek/utms/core/config/AuditLoggingAspect.class new file mode 100644 index 0000000..715f074 Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/AuditLoggingAspect.class differ diff --git a/target/classes/id/iptek/utms/core/config/DataSeeder.class b/target/classes/id/iptek/utms/core/config/DataSeeder.class new file mode 100644 index 0000000..cb0ffcb Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/DataSeeder.class differ diff --git a/target/classes/id/iptek/utms/core/config/I18nConfig.class b/target/classes/id/iptek/utms/core/config/I18nConfig.class new file mode 100644 index 0000000..e348f63 Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/I18nConfig.class differ diff --git a/target/classes/id/iptek/utms/core/config/JpaAuditConfig.class b/target/classes/id/iptek/utms/core/config/JpaAuditConfig.class new file mode 100644 index 0000000..1c28de8 Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/JpaAuditConfig.class differ diff --git a/target/classes/id/iptek/utms/core/config/LocaleConfig.class b/target/classes/id/iptek/utms/core/config/LocaleConfig.class new file mode 100644 index 0000000..2eab88a Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/LocaleConfig.class differ diff --git a/target/classes/id/iptek/utms/core/config/OpenApiConfig.class b/target/classes/id/iptek/utms/core/config/OpenApiConfig.class new file mode 100644 index 0000000..2d2e3eb Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/OpenApiConfig.class differ diff --git a/target/classes/id/iptek/utms/core/config/RedisConfig.class b/target/classes/id/iptek/utms/core/config/RedisConfig.class new file mode 100644 index 0000000..ecd49d3 Binary files /dev/null and b/target/classes/id/iptek/utms/core/config/RedisConfig.class differ diff --git a/target/classes/id/iptek/utms/core/domain/BaseEntity.class b/target/classes/id/iptek/utms/core/domain/BaseEntity.class new file mode 100644 index 0000000..b1d3323 Binary files /dev/null and b/target/classes/id/iptek/utms/core/domain/BaseEntity.class differ diff --git a/target/classes/id/iptek/utms/core/domain/TenantEntityListener.class b/target/classes/id/iptek/utms/core/domain/TenantEntityListener.class new file mode 100644 index 0000000..6507041 Binary files /dev/null and b/target/classes/id/iptek/utms/core/domain/TenantEntityListener.class differ diff --git a/target/classes/id/iptek/utms/core/exception/AppException.class b/target/classes/id/iptek/utms/core/exception/AppException.class new file mode 100644 index 0000000..dfa433e Binary files /dev/null and b/target/classes/id/iptek/utms/core/exception/AppException.class differ diff --git a/target/classes/id/iptek/utms/core/exception/GlobalExceptionHandler.class b/target/classes/id/iptek/utms/core/exception/GlobalExceptionHandler.class new file mode 100644 index 0000000..921f819 Binary files /dev/null and b/target/classes/id/iptek/utms/core/exception/GlobalExceptionHandler.class differ diff --git a/target/classes/id/iptek/utms/core/i18n/MessageResolver.class b/target/classes/id/iptek/utms/core/i18n/MessageResolver.class new file mode 100644 index 0000000..6fe0841 Binary files /dev/null and b/target/classes/id/iptek/utms/core/i18n/MessageResolver.class differ diff --git a/target/classes/id/iptek/utms/core/security/SecurityUtils.class b/target/classes/id/iptek/utms/core/security/SecurityUtils.class new file mode 100644 index 0000000..7e70a7c Binary files /dev/null and b/target/classes/id/iptek/utms/core/security/SecurityUtils.class differ diff --git a/target/classes/id/iptek/utms/messaging/ApprovalCompletedEvent.class b/target/classes/id/iptek/utms/messaging/ApprovalCompletedEvent.class new file mode 100644 index 0000000..2ddc9d1 Binary files /dev/null and b/target/classes/id/iptek/utms/messaging/ApprovalCompletedEvent.class differ diff --git a/target/classes/id/iptek/utms/messaging/ApprovalEventConsumer.class b/target/classes/id/iptek/utms/messaging/ApprovalEventConsumer.class new file mode 100644 index 0000000..ecb51d2 Binary files /dev/null and b/target/classes/id/iptek/utms/messaging/ApprovalEventConsumer.class differ diff --git a/target/classes/id/iptek/utms/messaging/ApprovalEventProducer.class b/target/classes/id/iptek/utms/messaging/ApprovalEventProducer.class new file mode 100644 index 0000000..1ef855a Binary files /dev/null and b/target/classes/id/iptek/utms/messaging/ApprovalEventProducer.class differ diff --git a/target/classes/id/iptek/utms/module/controller/ModuleController.class b/target/classes/id/iptek/utms/module/controller/ModuleController.class new file mode 100644 index 0000000..61cf8ac Binary files /dev/null and b/target/classes/id/iptek/utms/module/controller/ModuleController.class differ diff --git a/target/classes/id/iptek/utms/module/domain/SystemModule.class b/target/classes/id/iptek/utms/module/domain/SystemModule.class new file mode 100644 index 0000000..198676f Binary files /dev/null and b/target/classes/id/iptek/utms/module/domain/SystemModule.class differ diff --git a/target/classes/id/iptek/utms/module/dto/ModuleResponse.class b/target/classes/id/iptek/utms/module/dto/ModuleResponse.class new file mode 100644 index 0000000..528563e Binary files /dev/null and b/target/classes/id/iptek/utms/module/dto/ModuleResponse.class differ diff --git a/target/classes/id/iptek/utms/module/dto/ModuleToggleRequest.class b/target/classes/id/iptek/utms/module/dto/ModuleToggleRequest.class new file mode 100644 index 0000000..ab8009b Binary files /dev/null and b/target/classes/id/iptek/utms/module/dto/ModuleToggleRequest.class differ diff --git a/target/classes/id/iptek/utms/module/repository/SystemModuleRepository.class b/target/classes/id/iptek/utms/module/repository/SystemModuleRepository.class new file mode 100644 index 0000000..8ebb56b Binary files /dev/null and b/target/classes/id/iptek/utms/module/repository/SystemModuleRepository.class differ diff --git a/target/classes/id/iptek/utms/module/service/Module.class b/target/classes/id/iptek/utms/module/service/Module.class new file mode 100644 index 0000000..2f9641f Binary files /dev/null and b/target/classes/id/iptek/utms/module/service/Module.class differ diff --git a/target/classes/id/iptek/utms/module/service/ModuleRegistryService.class b/target/classes/id/iptek/utms/module/service/ModuleRegistryService.class new file mode 100644 index 0000000..91628e8 Binary files /dev/null and b/target/classes/id/iptek/utms/module/service/ModuleRegistryService.class differ diff --git a/target/classes/id/iptek/utms/module/service/NotificationModule.class b/target/classes/id/iptek/utms/module/service/NotificationModule.class new file mode 100644 index 0000000..a410986 Binary files /dev/null and b/target/classes/id/iptek/utms/module/service/NotificationModule.class differ diff --git a/target/classes/id/iptek/utms/preference/domain/UserUiPreference.class b/target/classes/id/iptek/utms/preference/domain/UserUiPreference.class new file mode 100644 index 0000000..e4f913a Binary files /dev/null and b/target/classes/id/iptek/utms/preference/domain/UserUiPreference.class differ diff --git a/target/classes/id/iptek/utms/preference/dto/TablePreferenceProfile.class b/target/classes/id/iptek/utms/preference/dto/TablePreferenceProfile.class new file mode 100644 index 0000000..007e29e Binary files /dev/null and b/target/classes/id/iptek/utms/preference/dto/TablePreferenceProfile.class differ diff --git a/target/classes/id/iptek/utms/preference/dto/TablePreferenceRequest.class b/target/classes/id/iptek/utms/preference/dto/TablePreferenceRequest.class new file mode 100644 index 0000000..571354b Binary files /dev/null and b/target/classes/id/iptek/utms/preference/dto/TablePreferenceRequest.class differ diff --git a/target/classes/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.class b/target/classes/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.class new file mode 100644 index 0000000..522937e Binary files /dev/null and b/target/classes/id/iptek/utms/preference/dto/TablePreferenceSavedProfile.class differ diff --git a/target/classes/id/iptek/utms/preference/dto/UserUiPreferencesResponse.class b/target/classes/id/iptek/utms/preference/dto/UserUiPreferencesResponse.class new file mode 100644 index 0000000..f017901 Binary files /dev/null and b/target/classes/id/iptek/utms/preference/dto/UserUiPreferencesResponse.class differ diff --git a/target/classes/id/iptek/utms/preference/repository/UserUiPreferenceRepository.class b/target/classes/id/iptek/utms/preference/repository/UserUiPreferenceRepository.class new file mode 100644 index 0000000..9f385b1 Binary files /dev/null and b/target/classes/id/iptek/utms/preference/repository/UserUiPreferenceRepository.class differ diff --git a/target/classes/id/iptek/utms/preference/service/UserPreferenceService.class b/target/classes/id/iptek/utms/preference/service/UserPreferenceService.class new file mode 100644 index 0000000..608c122 Binary files /dev/null and b/target/classes/id/iptek/utms/preference/service/UserPreferenceService.class differ diff --git a/target/classes/id/iptek/utms/tenant/Tenant.class b/target/classes/id/iptek/utms/tenant/Tenant.class new file mode 100644 index 0000000..ab3a084 Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/Tenant.class differ diff --git a/target/classes/id/iptek/utms/tenant/TenantContext.class b/target/classes/id/iptek/utms/tenant/TenantContext.class new file mode 100644 index 0000000..d8ee42a Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/TenantContext.class differ diff --git a/target/classes/id/iptek/utms/tenant/TenantFilter.class b/target/classes/id/iptek/utms/tenant/TenantFilter.class new file mode 100644 index 0000000..a807be5 Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/TenantFilter.class differ diff --git a/target/classes/id/iptek/utms/tenant/TenantHibernateFilter.class b/target/classes/id/iptek/utms/tenant/TenantHibernateFilter.class new file mode 100644 index 0000000..82894f7 Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/TenantHibernateFilter.class differ diff --git a/target/classes/id/iptek/utms/tenant/TenantRepository.class b/target/classes/id/iptek/utms/tenant/TenantRepository.class new file mode 100644 index 0000000..4a2ad40 Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/TenantRepository.class differ diff --git a/target/classes/id/iptek/utms/tenant/TenantService.class b/target/classes/id/iptek/utms/tenant/TenantService.class new file mode 100644 index 0000000..2566ec7 Binary files /dev/null and b/target/classes/id/iptek/utms/tenant/TenantService.class differ diff --git a/target/classes/id/iptek/utms/workflow/controller/ApprovalWorkflowController.class b/target/classes/id/iptek/utms/workflow/controller/ApprovalWorkflowController.class new file mode 100644 index 0000000..e0497a5 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/controller/ApprovalWorkflowController.class differ diff --git a/target/classes/id/iptek/utms/workflow/domain/ApprovalAction.class b/target/classes/id/iptek/utms/workflow/domain/ApprovalAction.class new file mode 100644 index 0000000..643ee03 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/domain/ApprovalAction.class differ diff --git a/target/classes/id/iptek/utms/workflow/domain/ApprovalHistory.class b/target/classes/id/iptek/utms/workflow/domain/ApprovalHistory.class new file mode 100644 index 0000000..e91c325 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/domain/ApprovalHistory.class differ diff --git a/target/classes/id/iptek/utms/workflow/domain/ApprovalRequest.class b/target/classes/id/iptek/utms/workflow/domain/ApprovalRequest.class new file mode 100644 index 0000000..bc054f6 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/domain/ApprovalRequest.class differ diff --git a/target/classes/id/iptek/utms/workflow/domain/ApprovalStatus.class b/target/classes/id/iptek/utms/workflow/domain/ApprovalStatus.class new file mode 100644 index 0000000..80d5205 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/domain/ApprovalStatus.class differ diff --git a/target/classes/id/iptek/utms/workflow/domain/ApprovalStep.class b/target/classes/id/iptek/utms/workflow/domain/ApprovalStep.class new file mode 100644 index 0000000..e4831d3 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/domain/ApprovalStep.class differ diff --git a/target/classes/id/iptek/utms/workflow/dto/ApprovalActionRequest.class b/target/classes/id/iptek/utms/workflow/dto/ApprovalActionRequest.class new file mode 100644 index 0000000..1cce462 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/dto/ApprovalActionRequest.class differ diff --git a/target/classes/id/iptek/utms/workflow/dto/ApprovalRequestSummary.class b/target/classes/id/iptek/utms/workflow/dto/ApprovalRequestSummary.class new file mode 100644 index 0000000..6ed8a42 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/dto/ApprovalRequestSummary.class differ diff --git a/target/classes/id/iptek/utms/workflow/dto/ApprovalResponse.class b/target/classes/id/iptek/utms/workflow/dto/ApprovalResponse.class new file mode 100644 index 0000000..81893f1 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/dto/ApprovalResponse.class differ diff --git a/target/classes/id/iptek/utms/workflow/dto/CreateApprovalRequest.class b/target/classes/id/iptek/utms/workflow/dto/CreateApprovalRequest.class new file mode 100644 index 0000000..daadbe0 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/dto/CreateApprovalRequest.class differ diff --git a/target/classes/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.class b/target/classes/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.class new file mode 100644 index 0000000..a587437 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/repository/ApprovalHistoryRepository.class differ diff --git a/target/classes/id/iptek/utms/workflow/repository/ApprovalRequestRepository.class b/target/classes/id/iptek/utms/workflow/repository/ApprovalRequestRepository.class new file mode 100644 index 0000000..c5e4feb Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/repository/ApprovalRequestRepository.class differ diff --git a/target/classes/id/iptek/utms/workflow/repository/ApprovalStepRepository.class b/target/classes/id/iptek/utms/workflow/repository/ApprovalStepRepository.class new file mode 100644 index 0000000..0ee71e4 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/repository/ApprovalStepRepository.class differ diff --git a/target/classes/id/iptek/utms/workflow/service/ApprovalWorkflowService.class b/target/classes/id/iptek/utms/workflow/service/ApprovalWorkflowService.class new file mode 100644 index 0000000..eb53695 Binary files /dev/null and b/target/classes/id/iptek/utms/workflow/service/ApprovalWorkflowService.class differ diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..0019cbc --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,94 @@ +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\api\ApiResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\api\AuditController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\api\RoleController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\api\TenantController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\api\UserController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\config\JwtProperties.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\config\LdapAuthConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\config\LdapProperties.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\config\SecurityConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\controller\AuthController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\domain\AuthenticationSource.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\domain\Permission.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\domain\RefreshToken.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\domain\Role.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\domain\User.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\AuthTokenResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\CreateRoleManagementRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\CreateUserManagementRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\CurrentUserResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\LoginRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\RefreshRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\UpdateRolePermissionsRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\dto\UpdateUserRolesRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\repository\PermissionRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\repository\RefreshTokenRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\repository\RoleRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\repository\UserRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\security\JwtAuthenticationFilter.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\security\JwtService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\security\TenantAwareUserDetailsService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\security\UserPrincipal.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\AuthService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\LoginThrottleService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\SingleLoginSessionService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\TokenBlacklistService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\UserRoleManagementService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\auth\service\UserService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\audit\domain\AuditTrail.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\audit\dto\AuditTrailResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\audit\repository\AuditTrailRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\audit\service\AuditTrailService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\ActiveMqConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\AuditLoggingAspect.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\DataSeeder.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\I18nConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\JpaAuditConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\LocaleConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\OpenApiConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\config\RedisConfig.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\domain\BaseEntity.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\domain\TenantEntityListener.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\exception\AppException.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\exception\GlobalExceptionHandler.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\i18n\MessageResolver.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\core\security\SecurityUtils.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\messaging\ApprovalCompletedEvent.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\messaging\ApprovalEventConsumer.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\messaging\ApprovalEventProducer.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\controller\ModuleController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\domain\SystemModule.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\dto\ModuleResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\dto\ModuleToggleRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\repository\SystemModuleRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\service\Module.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\service\ModuleRegistryService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\module\service\NotificationModule.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\domain\UserUiPreference.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\dto\TablePreferenceProfile.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\dto\TablePreferenceRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\dto\TablePreferenceSavedProfile.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\dto\UserUiPreferencesResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\repository\UserUiPreferenceRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\preference\service\UserPreferenceService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\Tenant.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\TenantContext.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\TenantFilter.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\TenantHibernateFilter.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\TenantRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\tenant\TenantService.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\UtmsNgBeApplication.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\controller\ApprovalWorkflowController.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\domain\ApprovalAction.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\domain\ApprovalHistory.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\domain\ApprovalRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\domain\ApprovalStatus.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\domain\ApprovalStep.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\dto\ApprovalActionRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\dto\ApprovalRequestSummary.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\dto\ApprovalResponse.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\dto\CreateApprovalRequest.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\repository\ApprovalHistoryRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\repository\ApprovalRequestRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\repository\ApprovalStepRepository.java +D:\Projects\Personal\IPTEK\utms-ng\utms-ng-be\src\main\java\id\iptek\utms\workflow\service\ApprovalWorkflowService.java diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst new file mode 100644 index 0000000..e69de29