initial commit
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
445
README.md
Normal file
445
README.md
Normal 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
56
docker-compose.yml
Normal 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:
|
||||
335
docs/frontend-api-surface.md
Normal file
335
docs/frontend-api-surface.md
Normal 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
|
||||
299
docs/frontend-initial-prompt.md
Normal file
299
docs/frontend-initial-prompt.md
Normal 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
417
docs/sequence-diagrams.md
Normal 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
116
pom.xml
Normal 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>
|
||||
21
src/main/java/id/iptek/utms/UtmsNgBeApplication.java
Normal file
21
src/main/java/id/iptek/utms/UtmsNgBeApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
19
src/main/java/id/iptek/utms/api/ApiResponse.java
Normal file
19
src/main/java/id/iptek/utms/api/ApiResponse.java
Normal 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());
|
||||
}
|
||||
}
|
||||
|
||||
62
src/main/java/id/iptek/utms/api/AuditController.java
Normal file
62
src/main/java/id/iptek/utms/api/AuditController.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/main/java/id/iptek/utms/api/RoleController.java
Normal file
50
src/main/java/id/iptek/utms/api/RoleController.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/main/java/id/iptek/utms/api/TenantController.java
Normal file
31
src/main/java/id/iptek/utms/api/TenantController.java
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
||||
136
src/main/java/id/iptek/utms/api/UserController.java
Normal file
136
src/main/java/id/iptek/utms/api/UserController.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/main/java/id/iptek/utms/auth/config/JwtProperties.java
Normal file
12
src/main/java/id/iptek/utms/auth/config/JwtProperties.java
Normal 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
|
||||
) {
|
||||
}
|
||||
|
||||
45
src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java
Normal file
45
src/main/java/id/iptek/utms/auth/config/LdapAuthConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/main/java/id/iptek/utms/auth/config/LdapProperties.java
Normal file
17
src/main/java/id/iptek/utms/auth/config/LdapProperties.java
Normal 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
|
||||
) {
|
||||
}
|
||||
86
src/main/java/id/iptek/utms/auth/config/SecurityConfig.java
Normal file
86
src/main/java/id/iptek/utms/auth/config/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
package id.iptek.utms.auth.domain;
|
||||
|
||||
public enum AuthenticationSource {
|
||||
LOCAL,
|
||||
LDAP
|
||||
}
|
||||
37
src/main/java/id/iptek/utms/auth/domain/Permission.java
Normal file
37
src/main/java/id/iptek/utms/auth/domain/Permission.java
Normal 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<>();
|
||||
}
|
||||
|
||||
40
src/main/java/id/iptek/utms/auth/domain/RefreshToken.java
Normal file
40
src/main/java/id/iptek/utms/auth/domain/RefreshToken.java
Normal 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;
|
||||
}
|
||||
|
||||
45
src/main/java/id/iptek/utms/auth/domain/Role.java
Normal file
45
src/main/java/id/iptek/utms/auth/domain/Role.java
Normal 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<>();
|
||||
}
|
||||
|
||||
55
src/main/java/id/iptek/utms/auth/domain/User.java
Normal file
55
src/main/java/id/iptek/utms/auth/domain/User.java
Normal 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<>();
|
||||
}
|
||||
|
||||
10
src/main/java/id/iptek/utms/auth/dto/AuthTokenResponse.java
Normal file
10
src/main/java/id/iptek/utms/auth/dto/AuthTokenResponse.java
Normal file
@ -0,0 +1,10 @@
|
||||
package id.iptek.utms.auth.dto;
|
||||
|
||||
public record AuthTokenResponse(
|
||||
String tokenType,
|
||||
String accessToken,
|
||||
String refreshToken,
|
||||
long expiresInSeconds
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
10
src/main/java/id/iptek/utms/auth/dto/LoginRequest.java
Normal file
10
src/main/java/id/iptek/utms/auth/dto/LoginRequest.java
Normal 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
|
||||
) {
|
||||
}
|
||||
|
||||
9
src/main/java/id/iptek/utms/auth/dto/RefreshRequest.java
Normal file
9
src/main/java/id/iptek/utms/auth/dto/RefreshRequest.java
Normal file
@ -0,0 +1,9 @@
|
||||
package id.iptek.utms.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RefreshRequest(
|
||||
@NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
91
src/main/java/id/iptek/utms/auth/security/JwtService.java
Normal file
91
src/main/java/id/iptek/utms/auth/security/JwtService.java
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
87
src/main/java/id/iptek/utms/auth/security/UserPrincipal.java
Normal file
87
src/main/java/id/iptek/utms/auth/security/UserPrincipal.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
153
src/main/java/id/iptek/utms/auth/service/AuthService.java
Normal file
153
src/main/java/id/iptek/utms/auth/service/AuthService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
37
src/main/java/id/iptek/utms/auth/service/UserService.java
Normal file
37
src/main/java/id/iptek/utms/auth/service/UserService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
10
src/main/java/id/iptek/utms/core/config/ActiveMqConfig.java
Normal file
10
src/main/java/id/iptek/utms/core/config/ActiveMqConfig.java
Normal 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 {
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
183
src/main/java/id/iptek/utms/core/config/DataSeeder.java
Normal file
183
src/main/java/id/iptek/utms/core/config/DataSeeder.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
20
src/main/java/id/iptek/utms/core/config/I18nConfig.java
Normal file
20
src/main/java/id/iptek/utms/core/config/I18nConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/main/java/id/iptek/utms/core/config/JpaAuditConfig.java
Normal file
20
src/main/java/id/iptek/utms/core/config/JpaAuditConfig.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
||||
20
src/main/java/id/iptek/utms/core/config/LocaleConfig.java
Normal file
20
src/main/java/id/iptek/utms/core/config/LocaleConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
36
src/main/java/id/iptek/utms/core/config/OpenApiConfig.java
Normal file
36
src/main/java/id/iptek/utms/core/config/OpenApiConfig.java
Normal 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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
31
src/main/java/id/iptek/utms/core/config/RedisConfig.java
Normal file
31
src/main/java/id/iptek/utms/core/config/RedisConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
48
src/main/java/id/iptek/utms/core/domain/BaseEntity.java
Normal file
48
src/main/java/id/iptek/utms/core/domain/BaseEntity.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package id.iptek.utms.core.exception;
|
||||
|
||||
public class AppException extends RuntimeException {
|
||||
|
||||
public AppException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
20
src/main/java/id/iptek/utms/core/i18n/MessageResolver.java
Normal file
20
src/main/java/id/iptek/utms/core/i18n/MessageResolver.java
Normal 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());
|
||||
}
|
||||
}
|
||||
|
||||
19
src/main/java/id/iptek/utms/core/security/SecurityUtils.java
Normal file
19
src/main/java/id/iptek/utms/core/security/SecurityUtils.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
35
src/main/java/id/iptek/utms/module/domain/SystemModule.java
Normal file
35
src/main/java/id/iptek/utms/module/domain/SystemModule.java
Normal 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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package id.iptek.utms.module.dto;
|
||||
|
||||
public record ModuleResponse(
|
||||
String code,
|
||||
String name,
|
||||
boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package id.iptek.utms.module.dto;
|
||||
|
||||
public record ModuleToggleRequest(boolean enabled) {
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
8
src/main/java/id/iptek/utms/module/service/Module.java
Normal file
8
src/main/java/id/iptek/utms/module/service/Module.java
Normal file
@ -0,0 +1,8 @@
|
||||
package id.iptek.utms.module.service;
|
||||
|
||||
public interface Module {
|
||||
String code();
|
||||
void onEnabled(String tenantId);
|
||||
void onDisabled(String tenantId);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package id.iptek.utms.preference.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TablePreferenceProfile(
|
||||
String preferenceKey,
|
||||
List<String> visibleColumns
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
33
src/main/java/id/iptek/utms/tenant/Tenant.java
Normal file
33
src/main/java/id/iptek/utms/tenant/Tenant.java
Normal 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();
|
||||
}
|
||||
|
||||
30
src/main/java/id/iptek/utms/tenant/TenantContext.java
Normal file
30
src/main/java/id/iptek/utms/tenant/TenantContext.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
35
src/main/java/id/iptek/utms/tenant/TenantFilter.java
Normal file
35
src/main/java/id/iptek/utms/tenant/TenantFilter.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/main/java/id/iptek/utms/tenant/TenantRepository.java
Normal file
11
src/main/java/id/iptek/utms/tenant/TenantRepository.java
Normal 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);
|
||||
}
|
||||
|
||||
22
src/main/java/id/iptek/utms/tenant/TenantService.java
Normal file
22
src/main/java/id/iptek/utms/tenant/TenantService.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package id.iptek.utms.workflow.domain;
|
||||
|
||||
public enum ApprovalAction {
|
||||
CREATE,
|
||||
SUBMIT,
|
||||
APPROVE,
|
||||
REJECT
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package id.iptek.utms.workflow.domain;
|
||||
|
||||
public enum ApprovalStatus {
|
||||
DRAFT,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
package id.iptek.utms.workflow.dto;
|
||||
|
||||
public record ApprovalActionRequest(
|
||||
String notes,
|
||||
String checkerRole
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
Reference in New Issue
Block a user