initial commit

This commit is contained in:
2026-04-21 06:25:33 +07:00
commit 85efdb7714
214 changed files with 6821 additions and 0 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

445
README.md Normal file
View File

@ -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 <jwt>`
## 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).

56
docker-compose.yml Normal file
View File

@ -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:

View File

@ -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<T> = {
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 <accessToken>`
- `X-Tenant-Id: <tenantId>`
- 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

View File

@ -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://<host>` (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<T> = {
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 <accessToken>`
- `X-Tenant-Id: <tenantId>`
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_<code>` (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

417
docs/sequence-diagrams.md Normal file
View File

@ -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 <token>
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
```

116
pom.xml Normal file
View File

@ -0,0 +1,116 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>id.iptek</groupId>
<artifactId>utms-ng-be</artifactId>
<version>1.0.0</version>
<name>utms-ng-be</name>
<description>Multi-tenant Spring Boot backend with workflow and modular system</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-activemq</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-ldap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -0,0 +1,19 @@
package id.iptek.utms.api;
import java.time.Instant;
public record ApiResponse<T>(
boolean success,
String message,
T data,
Instant timestamp
) {
public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(true, message, data, Instant.now());
}
public static ApiResponse<Void> fail(String message) {
return new ApiResponse<>(false, message, null, Instant.now());
}
}

View File

@ -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<List<AuditTrailResponse>> listRecent(@RequestParam(defaultValue = "50") int limit) {
String tenantId = TenantContext.getRequiredTenantId();
List<AuditTrail> 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()
);
}
}

View File

@ -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<ApprovalResponse> 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<ApprovalResponse> updatePermissions(@Valid @RequestBody UpdateRolePermissionsRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(
messageResolver.get("role.management.request.created"),
userRoleManagementService.submitUpdateRolePermissionsRequest(request, servletRequest)
);
}
}

View File

@ -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<Map<String, String>> tenantContext() {
return ApiResponse.ok("Tenant context resolved",
Map.of("tenantId", TenantContext.getRequiredTenantId()));
}
}

View File

@ -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<CurrentUserResponse> 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<UserUiPreferencesResponse> 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<TablePreferenceSavedProfile> 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<TablePreferenceProfile> 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<Void> 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<ApprovalResponse> 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<ApprovalResponse> 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"));
}
}
}

View File

@ -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
) {
}

View File

@ -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;
}
}

View File

@ -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
) {
}

View File

@ -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();
}
}

View File

@ -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<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(messageResolver.get("auth.login.success"), authService.login(request));
}
@PostMapping("/refresh")
@Operation(summary = "Refresh token")
public ApiResponse<AuthTokenResponse> 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<Void> 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);
}
}

View File

@ -0,0 +1,6 @@
package id.iptek.utms.auth.domain;
public enum AuthenticationSource {
LOCAL,
LDAP
}

View File

@ -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<Role> roles = new HashSet<>();
}

View File

@ -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;
}

View File

@ -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<Permission> permissions = new HashSet<>();
@ManyToMany(mappedBy = "roles")
private Set<User> users = new HashSet<>();
}

View File

@ -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<Role> roles = new HashSet<>();
}

View File

@ -0,0 +1,10 @@
package id.iptek.utms.auth.dto;
public record AuthTokenResponse(
String tokenType,
String accessToken,
String refreshToken,
long expiresInSeconds
) {
}

View File

@ -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
) {
}

View File

@ -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();
}
}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.auth.dto;
import java.util.Set;
public record CurrentUserResponse(
String tenantId,
String username,
Set<String> roles,
Set<String> permissions
) {
}

View File

@ -0,0 +1,10 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {
}

View File

@ -0,0 +1,9 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record RefreshRequest(
@NotBlank String refreshToken
) {
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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<Permission, UUID> {
Optional<Permission> findByTenantIdAndCode(String tenantId, String code);
List<Permission> findByTenantIdAndCodeIn(String tenantId, Collection<String> codes);
}

View File

@ -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<RefreshToken, UUID> {
Optional<RefreshToken> findByTokenAndTenantId(String token, String tenantId);
void deleteByUser_IdAndTenantId(UUID userId, String tenantId);
}

View File

@ -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<Role, UUID> {
Optional<Role> findByTenantIdAndCode(String tenantId, String code);
List<Role> findByTenantIdAndCodeIn(String tenantId, Collection<String> codes);
}

View File

@ -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<User, UUID> {
Optional<User> findByTenantIdAndUsername(String tenantId, String username);
boolean existsByTenantIdAndUsername(String tenantId, String username);
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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"));
}
}

View File

@ -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<GrantedAuthority> 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<GrantedAuthority> mapAuthorities(Set<Role> roles) {
Set<GrantedAuthority> 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<? extends GrantedAuthority> 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;
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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<Role> roles = resolveRoles(tenantId, request.roleCodes());
if (!ldapProperties.enabled() && !request.hasPassword()) {
throw new AppException("Password is required when LDAP is disabled");
}
Map<String, Object> 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<Role> roles = resolveRoles(tenantId, request.roleCodes());
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> roleCodes = castToStringList(payload.get("roleCodes"));
Set<Role> 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<String, Object> 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<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String username = (String) payload.get("username");
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseThrow(() -> new AppException("User not found"));
List<String> roleCodes = castToStringList(payload.get("roleCodes"));
Set<Role> roles = resolveRoles(tenantId, roleCodes);
Map<String, Object> 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<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String code = (String) payload.get("code");
String name = (String) payload.get("name");
List<String> permissionCodes = castToStringList(payload.get("permissionCodes"));
Set<Permission> 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<String, Object> 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<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String code = (String) payload.get("code");
List<String> permissionCodes = castToStringList(payload.get("permissionCodes"));
Role role = roleRepository.findByTenantIdAndCode(tenantId, code)
.orElseThrow(() -> new AppException("Role not found"));
Set<Permission> permissions = resolvePermissions(tenantId, permissionCodes);
Map<String, Object> 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<String, Object> before,
Map<String, Object> 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<Role> resolveRoles(String tenantId, Collection<String> roleCodes) {
if (CollectionUtils.isEmpty(roleCodes)) {
return Set.of();
}
Set<Role> 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<Permission> resolvePermissions(String tenantId, Collection<String> permissionCodes) {
if (CollectionUtils.isEmpty(permissionCodes)) {
return Set.of();
}
List<Permission> permissions = permissionRepository.findByTenantIdAndCodeIn(tenantId, permissionCodes);
if (permissions.size() != permissionCodes.size()) {
throw new AppException("Some permission codes are invalid");
}
return new LinkedHashSet<>(permissions);
}
private List<String> castToStringList(Object raw) {
if (!(raw instanceof Collection<?> values) || CollectionUtils.isEmpty(values)) {
return List.of();
}
return values.stream()
.map(String::valueOf)
.toList();
}
private Map<String, Object> snapshotUser(User user) {
Map<String, Object> 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<String, Object> snapshotRole(Role role) {
Map<String, Object> 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<String, Object> payload) {
return auditTrailService.toJson(payload);
}
}

View File

@ -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<String> roleCodes = user.getRoles().stream().map(Role::getCode).collect(Collectors.toSet());
Set<String> permissions = user.getRoles().stream()
.flatMap(role -> role.getPermissions().stream())
.map(permission -> permission.getCode())
.collect(Collectors.toSet());
return new CurrentUserResponse(tenantId, user.getUsername(), roleCodes, permissions);
}
}

View File

@ -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;
}

View File

@ -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
) {}

View File

@ -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<AuditTrail, UUID> {
Page<AuditTrail> findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable);
}

View File

@ -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<String, Object> before, Map<String, Object> after) {
return toJson(Map.of(
"event", event,
"before", before,
"after", after
));
}
public List<AuditTrail> 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();
}
}

View File

@ -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 {
}

View File

@ -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;
}
}

View File

@ -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<Permission> 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<Role> 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 <T> void addMissingByCode(Collection<T> target, Collection<T> requested, java.util.function.Function<T, String> codeExtractor) {
Set<String> existingCodes = target.stream()
.map(codeExtractor)
.collect(Collectors.toSet());
List<T> missing = requested.stream()
.filter(item -> item != null && !existingCodes.contains(codeExtractor.apply(item)))
.toList();
target.addAll(missing);
}
}

View File

@ -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;
}
}

View File

@ -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<String> auditorAware() {
return () -> Optional.ofNullable(SecurityUtils.currentUsername()).or(() -> Optional.of("system"));
}
}

View File

@ -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;
}
}

View File

@ -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 <token>'."))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components().addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.name("Authorization")
.type(Type.HTTP)
.in(In.HEADER)
.scheme("bearer")
.bearerFormat("JWT")
));
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,9 @@
package id.iptek.utms.core.exception;
public class AppException extends RuntimeException {
public AppException(String message) {
super(message);
}
}

View File

@ -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<ApiResponse<Void>> handleAppException(AppException ex) {
return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied() {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail(messageResolver.get("error.forbidden")));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(messageResolver.get("error.internal")));
}
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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 {
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<ModuleResponse>> list() {
return ApiResponse.ok(messageResolver.get("module.list.success"), moduleRegistryService.listModules());
}
@PostMapping("/{code}/toggle")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<ModuleResponse> toggle(@PathVariable String code,
@RequestBody ModuleToggleRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(messageResolver.get("module.toggle.success"),
moduleRegistryService.setEnabled(code, request.enabled(), servletRequest));
}
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
package id.iptek.utms.module.dto;
public record ModuleResponse(
String code,
String name,
boolean enabled
) {
}

View File

@ -0,0 +1,5 @@
package id.iptek.utms.module.dto;
public record ModuleToggleRequest(boolean enabled) {
}

View File

@ -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<SystemModule, UUID> {
Optional<SystemModule> findByTenantIdAndCode(String tenantId, String code);
List<SystemModule> findByTenantId(String tenantId);
}

View File

@ -0,0 +1,8 @@
package id.iptek.utms.module.service;
public interface Module {
String code();
void onEnabled(String tenantId);
void onDisabled(String tenantId);
}

View File

@ -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<String, Module> moduleHandlers;
private final AuditTrailService auditTrailService;
public ModuleRegistryService(SystemModuleRepository systemModuleRepository,
List<Module> moduleHandlers,
AuditTrailService auditTrailService) {
this.systemModuleRepository = systemModuleRepository;
this.moduleHandlers = moduleHandlers.stream().collect(Collectors.toMap(Module::code, Function.identity()));
this.auditTrailService = auditTrailService;
}
public List<ModuleResponse> 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<String, Object> moduleSnapshot(SystemModule module) {
Map<String, Object> 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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,10 @@
package id.iptek.utms.preference.dto;
import java.util.List;
public record TablePreferenceProfile(
String preferenceKey,
List<String> visibleColumns
) {
}

View File

@ -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
) {
}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.preference.dto;
import java.time.Instant;
import java.util.List;
public record TablePreferenceSavedProfile(
String preferenceKey,
List<String> visibleColumns,
Instant updatedAt
) {
}

View File

@ -0,0 +1,11 @@
package id.iptek.utms.preference.dto;
import java.time.Instant;
import java.util.List;
public record UserUiPreferencesResponse(
List<TablePreferenceProfile> columns,
Instant updatedAt
) {
}

View File

@ -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<UserUiPreference, UUID> {
List<UserUiPreference> findByUserId(UUID userId);
Optional<UserUiPreference> findByUserIdAndPreferenceKey(UUID userId, String preferenceKey);
void deleteByUserIdAndPreferenceKey(UUID userId, String preferenceKey);
void deleteByUserId(UUID userId);
}

View File

@ -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<String, List<String>> 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<UserUiPreference> preferences = repository.findByUserId(userId);
preferences.sort(Comparator.comparing(UserUiPreference::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())));
List<TablePreferenceProfile> 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<String> 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<String> normalizeVisibleColumns(List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
throw new AppException(messageResolver.get("user.preferences.invalid.columns"));
}
List<String> 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<String> visibleColumns) {
try {
Map<String, List<String>> 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<String> 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<String> 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<String> getDefaultColumns(String preferenceKey) {
return DEFAULT_COLUMNS_BY_KEY.getOrDefault(preferenceKey, List.of("id", "createdAt", "updatedAt", "actions"));
}
}

View File

@ -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();
}

View File

@ -0,0 +1,30 @@
package id.iptek.utms.tenant;
public final class TenantContext {
private static final ThreadLocal<String> 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();
}
}

View File

@ -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();
}
}
}

View File

@ -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");
}
}
}
}

View File

@ -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<Tenant, UUID> {
Optional<Tenant> findByTenantIdAndActiveTrue(String tenantId);
}

View File

@ -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"));
}
}

View File

@ -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<ApprovalResponse> 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<ApprovalResponse> 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<ApprovalResponse> 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<List<ApprovalRequestSummary>> 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));
}
}

View File

@ -0,0 +1,9 @@
package id.iptek.utms.workflow.domain;
public enum ApprovalAction {
CREATE,
SUBMIT,
APPROVE,
REJECT
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
package id.iptek.utms.workflow.domain;
public enum ApprovalStatus {
DRAFT,
PENDING,
APPROVED,
REJECTED
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
package id.iptek.utms.workflow.dto;
public record ApprovalActionRequest(
String notes,
String checkerRole
) {
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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<ApprovalHistory, UUID> {
}

View File

@ -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<ApprovalRequest, UUID> {
Optional<ApprovalRequest> findByIdAndTenantId(UUID id, String tenantId);
List<ApprovalRequest> findByTenantIdAndStatus(String tenantId, ApprovalStatus status, Pageable pageable);
List<ApprovalRequest> 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<ApprovalRequest> findByTenantIdWithFilters(@Param("tenantId") String tenantId,
@Param("status") ApprovalStatus status,
@Param("resourceType") String resourceType,
@Param("makerUsername") String makerUsername,
Pageable pageable);
}

View File

@ -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<ApprovalStep, UUID> {
Optional<ApprovalStep> findByRequestIdAndTenantIdAndStepOrder(UUID requestId, String tenantId, Integer stepOrder);
}

Some files were not shown because too many files have changed in this diff Show More